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:
Greg Thornton 2020-05-04 19:03:46 -05:00 committed by GitHub
parent f9b420a5a5
commit dd715fcc3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 691 additions and 3 deletions

View file

@ -527,6 +527,7 @@ class HomeKit:
def _start(self, bridged_states):
from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel
type_cameras,
type_covers,
type_fans,
type_lights,

View file

@ -208,6 +208,9 @@ def get_accessory(hass, driver, state, aid, config):
elif state.domain == "water_heater":
a_type = "WaterHeater"
elif state.domain == "camera":
a_type = "Camera"
if a_type is None:
return None
@ -219,10 +222,19 @@ class HomeAccessory(Accessory):
"""Adapter class for Accessory."""
def __init__(
self, hass, driver, name, entity_id, aid, config, category=CATEGORY_OTHER
self,
hass,
driver,
name,
entity_id,
aid,
config,
*args,
category=CATEGORY_OTHER,
**kwargs,
):
"""Initialize a Accessory object."""
super().__init__(driver, name, aid=aid)
super().__init__(driver=driver, display_name=name, aid=aid, *args, **kwargs)
model = split_entity_id(entity_id)[0].replace("_", " ").title()
self.set_info_service(
firmware_revision=__version__,
@ -459,6 +471,18 @@ class HomeBridge(Bridge):
def setup_message(self):
"""Prevent print of pyhap setup message to terminal."""
def get_snapshot(self, info):
"""Get snapshot from accessory if supported."""
acc = self.accessories.get(info["aid"])
if acc is None:
raise ValueError("Requested snapshot for missing accessory")
if not hasattr(acc, "get_snapshot"):
raise ValueError(
"Got a request for snapshot, but the Accessory "
'does not define a "get_snapshot" method'
)
return acc.get_snapshot(info)
class HomeDriver(AccessoryDriver):
"""Adapter class for AccessoryDriver."""

View file

@ -38,6 +38,7 @@ SUPPORTED_DOMAINS = [
"alarm_control_panel",
"automation",
"binary_sensor",
"camera",
"climate",
"cover",
"demo",

View file

@ -19,6 +19,8 @@ ATTR_VALUE = "value"
# #### Config ####
CONF_ADVERTISE_IP = "advertise_ip"
CONF_AUDIO_MAP = "audio_map"
CONF_AUDIO_PACKET_SIZE = "audio_packet_size"
CONF_AUTO_START = "auto_start"
CONF_ENTITY_CONFIG = "entity_config"
CONF_FEATURE = "feature"
@ -27,16 +29,31 @@ CONF_FILTER = "filter"
CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor"
CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor"
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
CONF_MAX_FPS = "max_fps"
CONF_MAX_HEIGHT = "max_height"
CONF_MAX_WIDTH = "max_width"
CONF_SAFE_MODE = "safe_mode"
CONF_ZEROCONF_DEFAULT_INTERFACE = "zeroconf_default_interface"
CONF_STREAM_ADDRESS = "stream_address"
CONF_STREAM_SOURCE = "stream_source"
CONF_SUPPORT_AUDIO = "support_audio"
CONF_VIDEO_MAP = "video_map"
CONF_VIDEO_PACKET_SIZE = "video_packet_size"
# #### Config Defaults ####
DEFAULT_AUDIO_MAP = "0:a:0"
DEFAULT_AUDIO_PACKET_SIZE = 188
DEFAULT_AUTO_START = True
DEFAULT_LOW_BATTERY_THRESHOLD = 20
DEFAULT_MAX_FPS = 30
DEFAULT_MAX_HEIGHT = 1080
DEFAULT_MAX_WIDTH = 1920
DEFAULT_PORT = 51827
DEFAULT_CONFIG_FLOW_PORT = 51828
DEFAULT_SAFE_MODE = False
DEFAULT_ZEROCONF_DEFAULT_INTERFACE = False
DEFAULT_VIDEO_MAP = "0:v:0"
DEFAULT_VIDEO_PACKET_SIZE = 1316
# #### Features ####
FEATURE_ON_OFF = "on_off"

View file

@ -3,7 +3,7 @@
"name": "HomeKit",
"documentation": "https://www.home-assistant.io/integrations/homekit",
"requirements": ["HAP-python==2.8.3","fnvhash==0.1.0","PyQRCode==1.2.1","base36==0.1.1"],
"dependencies": ["http"],
"dependencies": ["http", "camera", "ffmpeg"],
"after_dependencies": ["logbook"],
"codeowners": ["@bdraco"],
"config_flow": true

View file

@ -0,0 +1,241 @@
"""Class to hold all camera accessories."""
import asyncio
import logging
from haffmpeg.core import HAFFmpeg
from pyhap.camera import (
VIDEO_CODEC_PARAM_LEVEL_TYPES,
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES,
Camera as PyhapCamera,
)
from pyhap.const import CATEGORY_CAMERA
from homeassistant.components.camera.const import DOMAIN as DOMAIN_CAMERA
from homeassistant.components.ffmpeg import DATA_FFMPEG
from homeassistant.util import get_local_ip
from .accessories import TYPES, HomeAccessory
from .const import (
CONF_AUDIO_MAP,
CONF_AUDIO_PACKET_SIZE,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
CONF_MAX_WIDTH,
CONF_STREAM_ADDRESS,
CONF_STREAM_SOURCE,
CONF_SUPPORT_AUDIO,
CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE,
)
from .util import CAMERA_SCHEMA
_LOGGER = logging.getLogger(__name__)
VIDEO_OUTPUT = (
"-map {v_map} -an "
"-c:v libx264 -profile:v {v_profile} -tune zerolatency -pix_fmt yuv420p "
"-r {fps} "
"-b:v {v_max_bitrate}k -bufsize {v_bufsize}k -maxrate {v_max_bitrate}k "
"-payload_type 99 "
"-ssrc {v_ssrc} -f rtp "
"-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} "
"srtp://{address}:{v_port}?rtcpport={v_port}&"
"localrtcpport={v_port}&pkt_size={v_pkt_size}"
)
AUDIO_ENCODER_OPUS = "libopus -application lowdelay"
AUDIO_OUTPUT = (
"-map {a_map} -vn "
"-c:a {a_encoder} "
"-ac 1 -ar {a_sample_rate}k "
"-b:a {a_max_bitrate}k -bufsize {a_bufsize}k "
"-payload_type 110 "
"-ssrc {a_ssrc} -f rtp "
"-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} "
"srtp://{address}:{a_port}?rtcpport={a_port}&"
"localrtcpport={a_port}&pkt_size={a_pkt_size}"
)
SLOW_RESOLUTIONS = [
(320, 180, 15),
(320, 240, 15),
]
RESOLUTIONS = [
(320, 180),
(320, 240),
(480, 270),
(480, 360),
(640, 360),
(640, 480),
(1024, 576),
(1024, 768),
(1280, 720),
(1280, 960),
(1920, 1080),
]
VIDEO_PROFILE_NAMES = ["baseline", "main", "high"]
@TYPES.register("Camera")
class Camera(HomeAccessory, PyhapCamera):
"""Generate a Camera accessory."""
def __init__(self, hass, driver, name, entity_id, aid, config):
"""Initialize a Camera accessory object."""
self._ffmpeg = hass.data[DATA_FFMPEG]
self._camera = hass.data[DOMAIN_CAMERA]
config_w_defaults = CAMERA_SCHEMA(config)
max_fps = config_w_defaults[CONF_MAX_FPS]
max_width = config_w_defaults[CONF_MAX_WIDTH]
max_height = config_w_defaults[CONF_MAX_HEIGHT]
resolutions = [
(w, h, fps)
for w, h, fps in SLOW_RESOLUTIONS
if w <= max_width and h <= max_height and fps < max_fps
] + [
(w, h, max_fps)
for w, h in RESOLUTIONS
if w <= max_width and h <= max_height
]
video_options = {
"codec": {
"profiles": [
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["BASELINE"],
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["MAIN"],
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["HIGH"],
],
"levels": [
VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_1"],
VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_2"],
VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE4_0"],
],
},
"resolutions": resolutions,
}
audio_options = {"codecs": [{"type": "OPUS", "samplerate": 24}]}
stream_address = config_w_defaults.get(CONF_STREAM_ADDRESS, get_local_ip())
options = {
"video": video_options,
"audio": audio_options,
"address": stream_address,
"srtp": True,
}
super().__init__(
hass,
driver,
name,
entity_id,
aid,
config_w_defaults,
category=CATEGORY_CAMERA,
options=options,
)
def update_state(self, new_state):
"""Handle state change to update HomeKit value."""
pass # pylint: disable=unnecessary-pass
async def _async_get_stream_source(self):
"""Find the camera stream source url."""
camera = self._camera.get_entity(self.entity_id)
if not camera or not camera.is_on:
return None
stream_source = self.config.get(CONF_STREAM_SOURCE)
if stream_source:
return stream_source
try:
return await camera.stream_source()
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Failed to get stream source - this could be a transient error or your camera might not be compatible with HomeKit yet"
)
async def start_stream(self, session_info, stream_config):
"""Start a new stream with the given configuration."""
_LOGGER.debug(
"[%s] Starting stream with the following parameters: %s",
session_info["id"],
stream_config,
)
input_source = await self._async_get_stream_source()
if not input_source:
_LOGGER.error("Camera has no stream source")
return False
if "-i " not in input_source:
input_source = "-i " + input_source
output_vars = stream_config.copy()
output_vars.update(
{
"v_profile": VIDEO_PROFILE_NAMES[
int.from_bytes(stream_config["v_profile_id"], byteorder="big")
],
"v_bufsize": stream_config["v_max_bitrate"] * 2,
"v_map": self.config[CONF_VIDEO_MAP],
"v_pkt_size": self.config[CONF_VIDEO_PACKET_SIZE],
"a_bufsize": stream_config["a_max_bitrate"] * 2,
"a_map": self.config[CONF_AUDIO_MAP],
"a_pkt_size": self.config[CONF_AUDIO_PACKET_SIZE],
"a_encoder": AUDIO_ENCODER_OPUS,
}
)
output = VIDEO_OUTPUT.format(**output_vars)
if self.config[CONF_SUPPORT_AUDIO]:
output = output + " " + AUDIO_OUTPUT.format(**output_vars)
_LOGGER.debug("FFmpeg output settings: %s", output)
stream = HAFFmpeg(self._ffmpeg.binary, loop=self.driver.loop)
opened = await stream.open(
cmd=[], input_source=input_source, output=output, stdout_pipe=False
)
if not opened:
_LOGGER.error("Failed to open ffmpeg stream")
return False
session_info["stream"] = stream
_LOGGER.info(
"[%s] Started stream process - PID %d",
session_info["id"],
stream.process.pid,
)
return True
async def stop_stream(self, session_info):
"""Stop the stream for the given ``session_id``."""
session_id = session_info["id"]
stream = session_info.get("stream")
if not stream:
_LOGGER.debug("No stream for session ID %s", session_id)
_LOGGER.info("[%s] Stopping stream.", session_id)
try:
await stream.close()
return
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Failed to gracefully close stream.")
try:
await stream.kill()
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Failed to forcefully close stream.")
_LOGGER.debug("Stream process stopped forcefully.")
async def reconfigure_stream(self, session_info, stream_config):
"""Reconfigure the stream so that it uses the given ``stream_config``."""
return True
def get_snapshot(self, image_size):
"""Return a jpeg of a snapshot from the camera."""
return (
asyncio.run_coroutine_threadsafe(
self.hass.components.camera.async_get_image(self.entity_id),
self.hass.loop,
)
.result()
.content
)

View file

@ -1,6 +1,7 @@
"""Collection of useful functions for the HomeKit component."""
from collections import OrderedDict, namedtuple
import io
import ipaddress
import logging
import os
import secrets
@ -23,11 +24,28 @@ from homeassistant.helpers.storage import STORAGE_DIR
import homeassistant.util.temperature as temp_util
from .const import (
CONF_AUDIO_MAP,
CONF_AUDIO_PACKET_SIZE,
CONF_FEATURE,
CONF_FEATURE_LIST,
CONF_LINKED_BATTERY_SENSOR,
CONF_LOW_BATTERY_THRESHOLD,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
CONF_MAX_WIDTH,
CONF_STREAM_ADDRESS,
CONF_STREAM_SOURCE,
CONF_SUPPORT_AUDIO,
CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE,
DEFAULT_AUDIO_MAP,
DEFAULT_AUDIO_PACKET_SIZE,
DEFAULT_LOW_BATTERY_THRESHOLD,
DEFAULT_MAX_FPS,
DEFAULT_MAX_HEIGHT,
DEFAULT_MAX_WIDTH,
DEFAULT_VIDEO_MAP,
DEFAULT_VIDEO_PACKET_SIZE,
DOMAIN,
FEATURE_ON_OFF,
FEATURE_PLAY_PAUSE,
@ -62,6 +80,25 @@ FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(CONF_FEATURE_LIST, default=None): cv.ensure_list}
)
CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_STREAM_ADDRESS): vol.All(ipaddress.ip_address, cv.string),
vol.Optional(CONF_STREAM_SOURCE): cv.string,
vol.Optional(CONF_SUPPORT_AUDIO, default=False): cv.boolean,
vol.Optional(CONF_MAX_WIDTH, default=DEFAULT_MAX_WIDTH): cv.positive_int,
vol.Optional(CONF_MAX_HEIGHT, default=DEFAULT_MAX_HEIGHT): cv.positive_int,
vol.Optional(CONF_MAX_FPS, default=DEFAULT_MAX_FPS): cv.positive_int,
vol.Optional(CONF_AUDIO_MAP, default=DEFAULT_AUDIO_MAP): cv.string,
vol.Optional(CONF_VIDEO_MAP, default=DEFAULT_VIDEO_MAP): cv.string,
vol.Optional(
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
): cv.positive_int,
vol.Optional(
CONF_VIDEO_PACKET_SIZE, default=DEFAULT_VIDEO_PACKET_SIZE
): cv.positive_int,
}
)
CODE_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)}
)
@ -162,6 +199,9 @@ def validate_entity_config(values):
feature_list[key] = params
config[CONF_FEATURE_LIST] = feature_list
elif domain == "camera":
config = CAMERA_SCHEMA(config)
elif domain == "switch":
config = SWITCH_TYPE_SCHEMA(config)

View file

@ -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

View 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)