Add homekit camera support (#32527)
* Add homekit camera support * Cleanup pyhapcamera inheritance * Add camera to homekit manifest * Use upstream pyhap server handler in homekit * Remove unused homekit constants * Fix lint errors in homekit camera * Update homekit camera log messages * Black after conflict fixes * More conflict fixes * missing srtp * Allow streaming retry when ffmpeg fails to connect * Fix inherit of camera config, force kill ffmpeg on failure * Fix audio (Home Assistant only comes with OPUS) * Fix audio (Home Assistant only comes with OPUS) * Add camera to the list of supported domains. * add a test for camera creation * Add a basic test (still needs more as its only at 44% cover) * let super handle reconfigure_stream * Remove scaling as it does not appear to be needed and causes artifacts * Some more basic tests * make sure no exceptions when finding the source from the entity * make sure the bridge forwards get_snapshot * restore full coverage to accessories.py * revert usage of super for start/stop stream * one more test * more mocking * Remove -tune zerolatency, disable reconfigure_stream * Restore -tune zerolatency Co-authored-by: John Carr <john.carr@unrouted.co.uk> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
f9b420a5a5
commit
dd715fcc3a
9 changed files with 691 additions and 3 deletions
|
@ -264,3 +264,15 @@ def test_type_vacuum(type_name, entity_id, state, attrs):
|
|||
entity_state = State(entity_id, state, attrs)
|
||||
get_accessory(None, None, entity_state, 2, {})
|
||||
assert mock_type.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"type_name, entity_id, state, attrs", [("Camera", "camera.basic", "on", {})],
|
||||
)
|
||||
def test_type_camera(type_name, entity_id, state, attrs):
|
||||
"""Test if camera types are associated correctly."""
|
||||
mock_type = Mock()
|
||||
with patch.dict(TYPES, {type_name: mock_type}):
|
||||
entity_state = State(entity_id, state, attrs)
|
||||
get_accessory(None, None, entity_state, 2, {})
|
||||
assert mock_type.called
|
||||
|
|
352
tests/components/homekit/test_type_cameras.py
Normal file
352
tests/components/homekit/test_type_cameras.py
Normal file
|
@ -0,0 +1,352 @@
|
|||
"""Test different accessory types: Camera."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pyhap.accessory_driver import AccessoryDriver
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import camera, ffmpeg
|
||||
from homeassistant.components.homekit.accessories import HomeBridge
|
||||
from homeassistant.components.homekit.const import (
|
||||
CONF_STREAM_SOURCE,
|
||||
CONF_SUPPORT_AUDIO,
|
||||
)
|
||||
from homeassistant.components.homekit.type_cameras import Camera
|
||||
from homeassistant.components.homekit.type_switches import Switch
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.async_mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def run_driver(hass):
|
||||
"""Return a custom AccessoryDriver instance for HomeKit accessory init."""
|
||||
with patch("pyhap.accessory_driver.Zeroconf"), patch(
|
||||
"pyhap.accessory_driver.AccessoryEncoder"
|
||||
), patch("pyhap.accessory_driver.HAPServer"), patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.publish"
|
||||
), patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.persist"
|
||||
):
|
||||
yield AccessoryDriver(
|
||||
pincode=b"123-45-678", address="127.0.0.1", loop=hass.loop
|
||||
)
|
||||
|
||||
|
||||
def _get_working_mock_ffmpeg():
|
||||
"""Return a working ffmpeg."""
|
||||
ffmpeg = MagicMock()
|
||||
ffmpeg.open = AsyncMock(return_value=True)
|
||||
ffmpeg.close = AsyncMock(return_value=True)
|
||||
ffmpeg.kill = AsyncMock(return_value=True)
|
||||
return ffmpeg
|
||||
|
||||
|
||||
def _get_failing_mock_ffmpeg():
|
||||
"""Return an ffmpeg that fails to shutdown."""
|
||||
ffmpeg = MagicMock()
|
||||
ffmpeg.open = AsyncMock(return_value=False)
|
||||
ffmpeg.close = AsyncMock(side_effect=OSError)
|
||||
ffmpeg.kill = AsyncMock(side_effect=OSError)
|
||||
return ffmpeg
|
||||
|
||||
|
||||
async def test_camera_stream_source_configured(hass, run_driver, events):
|
||||
"""Test a camera that can stream with a configured source."""
|
||||
await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
|
||||
await async_setup_component(
|
||||
hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
entity_id = "camera.demo_camera"
|
||||
|
||||
hass.states.async_set(entity_id, None)
|
||||
await hass.async_block_till_done()
|
||||
acc = Camera(
|
||||
hass,
|
||||
run_driver,
|
||||
"Camera",
|
||||
entity_id,
|
||||
2,
|
||||
{CONF_STREAM_SOURCE: "/dev/null", CONF_SUPPORT_AUDIO: True},
|
||||
)
|
||||
not_camera_acc = Switch(hass, run_driver, "Switch", entity_id, 4, {},)
|
||||
bridge = HomeBridge("hass", run_driver, "Test Bridge")
|
||||
bridge.add_accessory(acc)
|
||||
bridge.add_accessory(not_camera_acc)
|
||||
|
||||
await acc.run_handler()
|
||||
|
||||
assert acc.aid == 2
|
||||
assert acc.category == 17 # Camera
|
||||
|
||||
stream_service = acc.get_service("CameraRTPStreamManagement")
|
||||
endpoints_config_char = stream_service.get_characteristic("SetupEndpoints")
|
||||
assert endpoints_config_char.setter_callback
|
||||
stream_config_char = stream_service.get_characteristic(
|
||||
"SelectedRTPStreamConfiguration"
|
||||
)
|
||||
assert stream_config_char.setter_callback
|
||||
acc.set_endpoints(
|
||||
"ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA=="
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.demo.camera.DemoCamera.stream_source",
|
||||
return_value=None,
|
||||
), patch(
|
||||
"homeassistant.components.homekit.type_cameras.HAFFmpeg",
|
||||
return_value=_get_working_mock_ffmpeg(),
|
||||
):
|
||||
acc.set_selected_stream_configuration(
|
||||
"ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
session_info = {
|
||||
"id": "mock",
|
||||
"v_srtp_key": "key",
|
||||
"a_srtp_key": "key",
|
||||
"v_port": "0",
|
||||
"a_port": "0",
|
||||
"address": "0.0.0.0",
|
||||
}
|
||||
acc.sessions[UUID("3303d503-17cc-469a-b672-92436a71a2f6")] = session_info
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.demo.camera.DemoCamera.stream_source",
|
||||
return_value="rtsp://example.local",
|
||||
), patch(
|
||||
"homeassistant.components.homekit.type_cameras.HAFFmpeg",
|
||||
return_value=_get_working_mock_ffmpeg(),
|
||||
):
|
||||
acc.set_selected_stream_configuration(
|
||||
"ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
||||
)
|
||||
await acc.stop_stream(session_info)
|
||||
# Calling a second time should not throw
|
||||
await acc.stop_stream(session_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await hass.async_add_executor_job(acc.get_snapshot, 1024)
|
||||
|
||||
# Verify the bridge only forwards get_snapshot for
|
||||
# cameras and valid accessory ids
|
||||
assert await hass.async_add_executor_job(bridge.get_snapshot, {"aid": 2})
|
||||
with pytest.raises(ValueError):
|
||||
assert await hass.async_add_executor_job(bridge.get_snapshot, {"aid": 3})
|
||||
with pytest.raises(ValueError):
|
||||
assert await hass.async_add_executor_job(bridge.get_snapshot, {"aid": 4})
|
||||
|
||||
|
||||
async def test_camera_stream_source_configured_with_failing_ffmpeg(
|
||||
hass, run_driver, events
|
||||
):
|
||||
"""Test a camera that can stream with a configured source with ffmpeg failing."""
|
||||
await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
|
||||
await async_setup_component(
|
||||
hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
entity_id = "camera.demo_camera"
|
||||
|
||||
hass.states.async_set(entity_id, None)
|
||||
await hass.async_block_till_done()
|
||||
acc = Camera(
|
||||
hass,
|
||||
run_driver,
|
||||
"Camera",
|
||||
entity_id,
|
||||
2,
|
||||
{CONF_STREAM_SOURCE: "/dev/null", CONF_SUPPORT_AUDIO: True},
|
||||
)
|
||||
not_camera_acc = Switch(hass, run_driver, "Switch", entity_id, 4, {},)
|
||||
bridge = HomeBridge("hass", run_driver, "Test Bridge")
|
||||
bridge.add_accessory(acc)
|
||||
bridge.add_accessory(not_camera_acc)
|
||||
|
||||
await acc.run_handler()
|
||||
|
||||
assert acc.aid == 2
|
||||
assert acc.category == 17 # Camera
|
||||
|
||||
stream_service = acc.get_service("CameraRTPStreamManagement")
|
||||
endpoints_config_char = stream_service.get_characteristic("SetupEndpoints")
|
||||
assert endpoints_config_char.setter_callback
|
||||
stream_config_char = stream_service.get_characteristic(
|
||||
"SelectedRTPStreamConfiguration"
|
||||
)
|
||||
assert stream_config_char.setter_callback
|
||||
acc.set_endpoints(
|
||||
"ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA=="
|
||||
)
|
||||
|
||||
session_info = {
|
||||
"id": "mock",
|
||||
"v_srtp_key": "key",
|
||||
"a_srtp_key": "key",
|
||||
"v_port": "0",
|
||||
"a_port": "0",
|
||||
"address": "0.0.0.0",
|
||||
}
|
||||
acc.sessions[UUID("3303d503-17cc-469a-b672-92436a71a2f6")] = session_info
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.demo.camera.DemoCamera.stream_source",
|
||||
return_value="rtsp://example.local",
|
||||
), patch(
|
||||
"homeassistant.components.homekit.type_cameras.HAFFmpeg",
|
||||
return_value=_get_failing_mock_ffmpeg(),
|
||||
):
|
||||
acc.set_selected_stream_configuration(
|
||||
"ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
||||
)
|
||||
await acc.stop_stream(session_info)
|
||||
# Calling a second time should not throw
|
||||
await acc.stop_stream(session_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_camera_stream_source_found(hass, run_driver, events):
|
||||
"""Test a camera that can stream and we get the source from the entity."""
|
||||
await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
|
||||
await async_setup_component(
|
||||
hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
entity_id = "camera.demo_camera"
|
||||
|
||||
hass.states.async_set(entity_id, None)
|
||||
await hass.async_block_till_done()
|
||||
acc = Camera(hass, run_driver, "Camera", entity_id, 2, {},)
|
||||
await acc.run_handler()
|
||||
|
||||
assert acc.aid == 2
|
||||
assert acc.category == 17 # Camera
|
||||
|
||||
stream_service = acc.get_service("CameraRTPStreamManagement")
|
||||
endpoints_config_char = stream_service.get_characteristic("SetupEndpoints")
|
||||
assert endpoints_config_char.setter_callback
|
||||
stream_config_char = stream_service.get_characteristic(
|
||||
"SelectedRTPStreamConfiguration"
|
||||
)
|
||||
assert stream_config_char.setter_callback
|
||||
acc.set_endpoints(
|
||||
"ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA=="
|
||||
)
|
||||
|
||||
session_info = {
|
||||
"id": "mock",
|
||||
"v_srtp_key": "key",
|
||||
"a_srtp_key": "key",
|
||||
"v_port": "0",
|
||||
"a_port": "0",
|
||||
"address": "0.0.0.0",
|
||||
}
|
||||
acc.sessions[UUID("3303d503-17cc-469a-b672-92436a71a2f6")] = session_info
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.demo.camera.DemoCamera.stream_source",
|
||||
return_value="rtsp://example.local",
|
||||
), patch(
|
||||
"homeassistant.components.homekit.type_cameras.HAFFmpeg",
|
||||
return_value=_get_working_mock_ffmpeg(),
|
||||
):
|
||||
acc.set_selected_stream_configuration(
|
||||
"ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
||||
)
|
||||
await acc.stop_stream(session_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.demo.camera.DemoCamera.stream_source",
|
||||
return_value="rtsp://example.local",
|
||||
), patch(
|
||||
"homeassistant.components.homekit.type_cameras.HAFFmpeg",
|
||||
return_value=_get_working_mock_ffmpeg(),
|
||||
):
|
||||
acc.set_selected_stream_configuration(
|
||||
"ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
||||
)
|
||||
await acc.stop_stream(session_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_camera_stream_source_fails(hass, run_driver, events):
|
||||
"""Test a camera that can stream and we cannot get the source from the entity."""
|
||||
await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
|
||||
await async_setup_component(
|
||||
hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
entity_id = "camera.demo_camera"
|
||||
|
||||
hass.states.async_set(entity_id, None)
|
||||
await hass.async_block_till_done()
|
||||
acc = Camera(hass, run_driver, "Camera", entity_id, 2, {},)
|
||||
await acc.run_handler()
|
||||
|
||||
assert acc.aid == 2
|
||||
assert acc.category == 17 # Camera
|
||||
|
||||
stream_service = acc.get_service("CameraRTPStreamManagement")
|
||||
endpoints_config_char = stream_service.get_characteristic("SetupEndpoints")
|
||||
assert endpoints_config_char.setter_callback
|
||||
stream_config_char = stream_service.get_characteristic(
|
||||
"SelectedRTPStreamConfiguration"
|
||||
)
|
||||
assert stream_config_char.setter_callback
|
||||
acc.set_endpoints(
|
||||
"ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA=="
|
||||
)
|
||||
|
||||
session_info = {
|
||||
"id": "mock",
|
||||
"v_srtp_key": "key",
|
||||
"a_srtp_key": "key",
|
||||
"v_port": "0",
|
||||
"a_port": "0",
|
||||
"address": "0.0.0.0",
|
||||
}
|
||||
acc.sessions[UUID("3303d503-17cc-469a-b672-92436a71a2f6")] = session_info
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.demo.camera.DemoCamera.stream_source",
|
||||
side_effect=OSError,
|
||||
), patch(
|
||||
"homeassistant.components.homekit.type_cameras.HAFFmpeg",
|
||||
return_value=_get_working_mock_ffmpeg(),
|
||||
):
|
||||
acc.set_selected_stream_configuration(
|
||||
"ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
||||
)
|
||||
await acc.stop_stream(session_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_camera_with_no_stream(hass, run_driver, events):
|
||||
"""Test a camera that cannot stream."""
|
||||
await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
|
||||
await async_setup_component(hass, camera.DOMAIN, {camera.DOMAIN: {}})
|
||||
|
||||
entity_id = "camera.demo_camera"
|
||||
|
||||
hass.states.async_set(entity_id, None)
|
||||
await hass.async_block_till_done()
|
||||
acc = Camera(hass, run_driver, "Camera", entity_id, 2, {},)
|
||||
await acc.run_handler()
|
||||
|
||||
assert acc.aid == 2
|
||||
assert acc.category == 17 # Camera
|
||||
|
||||
acc.set_endpoints(
|
||||
"ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA=="
|
||||
)
|
||||
acc.set_selected_stream_configuration(
|
||||
"ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA"
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.async_add_executor_job(acc.get_snapshot, 1024)
|
Loading…
Add table
Add a link
Reference in a new issue