From 18d03fbe191a3eb8daa677ef11c17cac0ad5e7b3 Mon Sep 17 00:00:00 2001 From: Greg Thornton Date: Fri, 6 Mar 2020 05:31:37 -0600 Subject: [PATCH 01/28] Add homekit camera support --- homeassistant/components/homekit/__init__.py | 4 +- .../components/homekit/accessories.py | 36 ++ homeassistant/components/homekit/const.py | 25 ++ .../components/homekit/manifest.json | 2 +- .../components/homekit/type_cameras.py | 422 ++++++++++++++++++ homeassistant/components/homekit/util.py | 40 ++ tests/components/homekit/test_accessories.py | 7 +- 7 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/homekit/type_cameras.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 184fce2309bba..41c200414ad86 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -344,13 +344,14 @@ def async_describe_logbook_event(event): value_msg = f" to {value}" if value else "" message = f"send command {data[ATTR_SERVICE]}{value_msg} for {data[ATTR_DISPLAY_NAME]}" - + return { "name": "HomeKit", "message": message, "entity_id": entity_id, } + hass.components.logbook.async_describe_event( DOMAIN, EVENT_HOMEKIT_CHANGED, async_describe_logbook_event ) @@ -527,6 +528,7 @@ async def _async_register_bridge(self): def _start(self, bridged_states): from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel + type_cameras, type_covers, type_fans, type_lights, diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index ddafbd8fa66a5..27cf5f02a6acc 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -2,11 +2,13 @@ from datetime import timedelta from functools import partial, wraps from inspect import getmodule +import json import logging from pyhap.accessory import Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER +from pyhap.hap_server import HAPServerHandler from homeassistant.components import cover, vacuum from homeassistant.components.cover import DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE @@ -209,6 +211,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 @@ -460,6 +465,36 @@ def __init__(self, hass, driver, name): 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 HomeServerHandler(HAPServerHandler): + """Manages HAP connection state and handles incoming HTTP requests.""" + + def handle_resource(self): + """Get a snapshot from the camera.""" + if not hasattr(self.accessory_handler.accessory, "get_snapshot"): + raise ValueError( + "Got a request for snapshot, but the Accessory " + 'does not define a "get_snapshot" method' + ) + data_len = int(self.headers["Content-Length"]) + image_size = json.loads(self.rfile.read(data_len).decode("utf-8")) + image = self.accessory_handler.accessory.get_snapshot(image_size) + self.send_response(200) + self.send_header("Content-Type", image.content_type) + self.end_response(image.content) + class HomeDriver(AccessoryDriver): """Adapter class for AccessoryDriver.""" @@ -470,6 +505,7 @@ def __init__(self, hass, entry_id, bridge_name, **kwargs): self.hass = hass self._entry_id = entry_id self._bridge_name = bridge_name + self.http_server.RequestHandlerClass = HomeServerHandler def pair(self, client_uuid, client_public): """Override super function to dismiss setup message if paired.""" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index ab0c15ee9a7eb..5cfaa47580b59 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -19,6 +19,8 @@ # #### 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_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" @@ -70,6 +87,7 @@ SERV_ACCESSORY_INFO = "AccessoryInformation" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" SERV_BATTERY_SERVICE = "BatteryService" +SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" @@ -81,6 +99,7 @@ SERV_LIGHT_SENSOR = "LightSensor" SERV_LIGHTBULB = "Lightbulb" SERV_LOCK = "LockMechanism" +SERV_MICROPHONE = "Microphone" SERV_MOTION_SENSOR = "MotionSensor" SERV_OCCUPANCY_SENSOR = "OccupancySensor" SERV_OUTLET = "Outlet" @@ -145,10 +164,16 @@ CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" CHAR_SATURATION = "Saturation" +CHAR_SELECTED_RTP_STREAM_CONFIGURATION = "SelectedRTPStreamConfiguration" CHAR_SERIAL_NUMBER = "SerialNumber" +CHAR_SETUP_ENDPOINTS = "SetupEndpoints" CHAR_SLEEP_DISCOVER_MODE = "SleepDiscoveryMode" CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" +CHAR_STREAMING_STATUS = "StreamingStatus" +CHAR_SUPPORTED_AUDIO_STREAM_CONFIGURATION = "SupportedAudioStreamConfiguration" +CHAR_SUPPORTED_RTP_CONFIGURATION = "SupportedRTPConfiguration" +CHAR_SUPPORTED_VIDEO_STREAM_CONFIGURATION = "SupportedVideoStreamConfiguration" CHAR_SWING_MODE = "SwingMode" CHAR_TARGET_DOOR_STATE = "TargetDoorState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 796bb3933f75d..de0d8083a7c7b 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -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", "ffmpeg"], "after_dependencies": ["logbook"], "codeowners": ["@bdraco"], "config_flow": true diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py new file mode 100644 index 0000000000000..453b8320d26b5 --- /dev/null +++ b/homeassistant/components/homekit/type_cameras.py @@ -0,0 +1,422 @@ +"""Class to hold all camera accessories.""" +import asyncio +import ipaddress +import logging +import os +import struct +from uuid import UUID + +from haffmpeg.core import HAFFmpeg +from pyhap import tlv +from pyhap.camera import ( + AUDIO_CODEC_TYPES, + NO_SRTP, + SETUP_ADDR_INFO, + SETUP_SRTP_PARAM, + SETUP_STATUS, + SETUP_TYPES, + SRTP_CRYPTO_SUITES, + STREAMING_STATUS, + VIDEO_CODEC_PARAM_LEVEL_TYPES, + VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, + Camera as PyhapCamera, +) +from pyhap.const import CATEGORY_CAMERA +from pyhap.util import to_base64_str + +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 . import TYPES +from .accessories import HomeAccessory +from .const import ( + CHAR_SELECTED_RTP_STREAM_CONFIGURATION, + CHAR_SETUP_ENDPOINTS, + CHAR_STREAMING_STATUS, + CHAR_SUPPORTED_AUDIO_STREAM_CONFIGURATION, + CHAR_SUPPORTED_RTP_CONFIGURATION, + CHAR_SUPPORTED_VIDEO_STREAM_CONFIGURATION, + 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, + SERV_CAMERA_RTP_STREAM_MANAGEMENT, + SERV_MICROPHONE, +) +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} " + "-vf 'scale=min({width}\\,iw):min({height}\\,ih):force_original_aspect_ratio=increase' " + "-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_ENCODER_AAC = "libfdk_aac -profile:a aac_eld -flags +global_header" + +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): + """Generate a Camera accessory.""" + + def __init__(self, *args): + """Initialize a Camera accessory object.""" + super().__init__(*args, category=CATEGORY_CAMERA) + self._ffmpeg = self.hass.data[DATA_FFMPEG] + self._camera = self.hass.data[DOMAIN_CAMERA] + + self.config = CAMERA_SCHEMA(self.config) + + max_fps = self.config[CONF_MAX_FPS] + max_width = self.config[CONF_MAX_WIDTH] + max_height = self.config[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}, + {"type": "AAC-eld", "samplerate": 16}, + ] + } + + self.streaming_status = STREAMING_STATUS["AVAILABLE"] + self.has_srtp = True + self.stream_address = self.config.get(CONF_STREAM_ADDRESS) or get_local_ip() + try: + ipaddress.IPv4Address(self.stream_address) + self.stream_address_isv6 = b"\x00" + except ValueError: + self.stream_address_isv6 = b"\x01" + self.sessions = {} + + if self.config[CONF_SUPPORT_AUDIO]: + self.add_preload_service(SERV_MICROPHONE) + management = self.add_preload_service(SERV_CAMERA_RTP_STREAM_MANAGEMENT) + management.configure_char( + CHAR_STREAMING_STATUS, getter_callback=self._get_streaming_status + ) + management.configure_char( + CHAR_SUPPORTED_RTP_CONFIGURATION, + value=PyhapCamera.get_supported_rtp_config(True), + ) + management.configure_char( + CHAR_SUPPORTED_VIDEO_STREAM_CONFIGURATION, + value=PyhapCamera.get_supported_video_stream_config(video_options), + ) + management.configure_char( + CHAR_SUPPORTED_AUDIO_STREAM_CONFIGURATION, + value=PyhapCamera.get_supported_audio_stream_config(audio_options), + ) + management.configure_char( + CHAR_SELECTED_RTP_STREAM_CONFIGURATION, + setter_callback=self.set_selected_stream_configuration, + ) + management.configure_char( + CHAR_SETUP_ENDPOINTS, setter_callback=self.set_endpoints + ) + + def update_state(self, new_state): + """Handle state change to update HomeKit value.""" + pass + + def _get_streaming_status(self): + return PyhapCamera._get_streaimg_status(self) + + async def _start_stream(self, objs, reconfigure): + return await PyhapCamera._start_stream(self, objs, reconfigure) + + async def _stop_stream(self, objs): + return await PyhapCamera._stop_stream(self, objs) + + def set_selected_stream_configuration(self, value): + """Set the selected stream configuration.""" + return PyhapCamera.set_selected_stream_configuration(self, value) + + def set_endpoints(self, value): + """Configure streaming endpoints.""" + objs = tlv.decode(value, from_base64=True) + session_id = UUID(bytes=objs[SETUP_TYPES["SESSION_ID"]]) + + # Extract address info + address_tlv = objs[SETUP_TYPES["ADDRESS"]] + address_info_objs = tlv.decode(address_tlv) + is_ipv6 = struct.unpack("?", address_info_objs[SETUP_ADDR_INFO["ADDRESS_VER"]])[ + 0 + ] + address = address_info_objs[SETUP_ADDR_INFO["ADDRESS"]].decode("utf8") + target_video_port = struct.unpack( + " Date: Mon, 9 Mar 2020 12:35:07 +0000 Subject: [PATCH 02/28] Cleanup pyhapcamera inheritance --- .../components/homekit/accessories.py | 13 +- .../components/homekit/type_cameras.py | 219 ++---------------- 2 files changed, 32 insertions(+), 200 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 27cf5f02a6acc..87f9a1019e4a4 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -225,10 +225,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__, diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 453b8320d26b5..97e82b4085ec1 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -1,28 +1,15 @@ """Class to hold all camera accessories.""" import asyncio -import ipaddress import logging -import os -import struct -from uuid import UUID from haffmpeg.core import HAFFmpeg -from pyhap import tlv from pyhap.camera import ( AUDIO_CODEC_TYPES, - NO_SRTP, - SETUP_ADDR_INFO, - SETUP_SRTP_PARAM, - SETUP_STATUS, - SETUP_TYPES, - SRTP_CRYPTO_SUITES, - STREAMING_STATUS, VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, Camera as PyhapCamera, ) from pyhap.const import CATEGORY_CAMERA -from pyhap.util import to_base64_str from homeassistant.components.camera.const import DOMAIN as DOMAIN_CAMERA from homeassistant.components.ffmpeg import DATA_FFMPEG @@ -31,12 +18,6 @@ from . import TYPES from .accessories import HomeAccessory from .const import ( - CHAR_SELECTED_RTP_STREAM_CONFIGURATION, - CHAR_SETUP_ENDPOINTS, - CHAR_STREAMING_STATUS, - CHAR_SUPPORTED_AUDIO_STREAM_CONFIGURATION, - CHAR_SUPPORTED_RTP_CONFIGURATION, - CHAR_SUPPORTED_VIDEO_STREAM_CONFIGURATION, CONF_AUDIO_MAP, CONF_AUDIO_PACKET_SIZE, CONF_MAX_FPS, @@ -47,8 +28,6 @@ CONF_SUPPORT_AUDIO, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, - SERV_CAMERA_RTP_STREAM_MANAGEMENT, - SERV_MICROPHONE, ) from .util import CAMERA_SCHEMA @@ -106,16 +85,15 @@ @TYPES.register("Camera") -class Camera(HomeAccessory): +class Camera(HomeAccessory, PyhapCamera): """Generate a Camera accessory.""" - def __init__(self, *args): + def __init__(self, hass, driver, name, entity_id, aid, config): """Initialize a Camera accessory object.""" - super().__init__(*args, category=CATEGORY_CAMERA) - self._ffmpeg = self.hass.data[DATA_FFMPEG] - self._camera = self.hass.data[DOMAIN_CAMERA] + self._ffmpeg = hass.data[DATA_FFMPEG] + self._camera = hass.data[DOMAIN_CAMERA] - self.config = CAMERA_SCHEMA(self.config) + self.config = CAMERA_SCHEMA(config) max_fps = self.config[CONF_MAX_FPS] max_width = self.config[CONF_MAX_WIDTH] @@ -152,184 +130,29 @@ def __init__(self, *args): ] } - self.streaming_status = STREAMING_STATUS["AVAILABLE"] - self.has_srtp = True - self.stream_address = self.config.get(CONF_STREAM_ADDRESS) or get_local_ip() - try: - ipaddress.IPv4Address(self.stream_address) - self.stream_address_isv6 = b"\x00" - except ValueError: - self.stream_address_isv6 = b"\x01" - self.sessions = {} + stream_address = self.config.get(CONF_STREAM_ADDRESS) or get_local_ip() - if self.config[CONF_SUPPORT_AUDIO]: - self.add_preload_service(SERV_MICROPHONE) - management = self.add_preload_service(SERV_CAMERA_RTP_STREAM_MANAGEMENT) - management.configure_char( - CHAR_STREAMING_STATUS, getter_callback=self._get_streaming_status - ) - management.configure_char( - CHAR_SUPPORTED_RTP_CONFIGURATION, - value=PyhapCamera.get_supported_rtp_config(True), - ) - management.configure_char( - CHAR_SUPPORTED_VIDEO_STREAM_CONFIGURATION, - value=PyhapCamera.get_supported_video_stream_config(video_options), - ) - management.configure_char( - CHAR_SUPPORTED_AUDIO_STREAM_CONFIGURATION, - value=PyhapCamera.get_supported_audio_stream_config(audio_options), - ) - management.configure_char( - CHAR_SELECTED_RTP_STREAM_CONFIGURATION, - setter_callback=self.set_selected_stream_configuration, - ) - management.configure_char( - CHAR_SETUP_ENDPOINTS, setter_callback=self.set_endpoints + options = { + "video": video_options, + "audio": audio_options, + "address": stream_address, + } + + super().__init__( + hass, + driver, + name, + entity_id, + aid, + config, + category=CATEGORY_CAMERA, + options=options, ) def update_state(self, new_state): """Handle state change to update HomeKit value.""" pass - def _get_streaming_status(self): - return PyhapCamera._get_streaimg_status(self) - - async def _start_stream(self, objs, reconfigure): - return await PyhapCamera._start_stream(self, objs, reconfigure) - - async def _stop_stream(self, objs): - return await PyhapCamera._stop_stream(self, objs) - - def set_selected_stream_configuration(self, value): - """Set the selected stream configuration.""" - return PyhapCamera.set_selected_stream_configuration(self, value) - - def set_endpoints(self, value): - """Configure streaming endpoints.""" - objs = tlv.decode(value, from_base64=True) - session_id = UUID(bytes=objs[SETUP_TYPES["SESSION_ID"]]) - - # Extract address info - address_tlv = objs[SETUP_TYPES["ADDRESS"]] - address_info_objs = tlv.decode(address_tlv) - is_ipv6 = struct.unpack("?", address_info_objs[SETUP_ADDR_INFO["ADDRESS_VER"]])[ - 0 - ] - address = address_info_objs[SETUP_ADDR_INFO["ADDRESS"]].decode("utf8") - target_video_port = struct.unpack( - " Date: Fri, 6 Mar 2020 05:31:37 -0600 Subject: [PATCH 03/28] Add camera to homekit manifest --- homeassistant/components/homekit/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index de0d8083a7c7b..3d0c84d31b5cc 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -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", "ffmpeg"], + "dependencies": ["http", "camera", "ffmpeg"], "after_dependencies": ["logbook"], "codeowners": ["@bdraco"], "config_flow": true From 6f0c2503e4fd4d662166393898801d83702f719f Mon Sep 17 00:00:00 2001 From: Greg Thornton Date: Fri, 6 Mar 2020 05:31:37 -0600 Subject: [PATCH 04/28] Use upstream pyhap server handler in homekit --- .../components/homekit/accessories.py | 21 ------------------- .../components/homekit/type_cameras.py | 13 ++++++++---- tests/components/homekit/test_accessories.py | 7 +------ 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 87f9a1019e4a4..659a69add3e18 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -2,13 +2,11 @@ from datetime import timedelta from functools import partial, wraps from inspect import getmodule -import json import logging from pyhap.accessory import Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER -from pyhap.hap_server import HAPServerHandler from homeassistant.components import cover, vacuum from homeassistant.components.cover import DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE @@ -487,24 +485,6 @@ def get_snapshot(self, info): return acc.get_snapshot(info) -class HomeServerHandler(HAPServerHandler): - """Manages HAP connection state and handles incoming HTTP requests.""" - - def handle_resource(self): - """Get a snapshot from the camera.""" - if not hasattr(self.accessory_handler.accessory, "get_snapshot"): - raise ValueError( - "Got a request for snapshot, but the Accessory " - 'does not define a "get_snapshot" method' - ) - data_len = int(self.headers["Content-Length"]) - image_size = json.loads(self.rfile.read(data_len).decode("utf-8")) - image = self.accessory_handler.accessory.get_snapshot(image_size) - self.send_response(200) - self.send_header("Content-Type", image.content_type) - self.end_response(image.content) - - class HomeDriver(AccessoryDriver): """Adapter class for AccessoryDriver.""" @@ -514,7 +494,6 @@ def __init__(self, hass, entry_id, bridge_name, **kwargs): self.hass = hass self._entry_id = entry_id self._bridge_name = bridge_name - self.http_server.RequestHandlerClass = HomeServerHandler def pair(self, client_uuid, client_public): """Override super function to dismiss setup message if paired.""" diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 97e82b4085ec1..6abc7b82ba0e3 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -239,7 +239,12 @@ async def reconfigure_stream(self, session_info, stream_config): return True def get_snapshot(self, image_size): - """Return an ``Image`` 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() + """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 + ) diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index ffd93c2ba059a..e2fb79f56ce0e 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -451,13 +451,8 @@ def test_home_driver(): port = 51826 path = ".homekit.state" pin = b"123-45-678" - mock_driver = Mock() - def mock_driver_init(self, **kwargs): - self.http_server = Mock() - mock_driver(**kwargs) - - with patch("pyhap.accessory_driver.AccessoryDriver.__init__", new=mock_driver_init): + with patch("pyhap.accessory_driver.AccessoryDriver.__init__") as mock_driver: driver = HomeDriver( "hass", "entry_id", "name", address=ip_address, port=port, persist_file=path ) From bfd19bb1881c1560957dce0e0aff68a8aee0721b Mon Sep 17 00:00:00 2001 From: Greg Thornton Date: Thu, 9 Apr 2020 03:23:56 -0500 Subject: [PATCH 05/28] Remove unused homekit constants --- homeassistant/components/homekit/const.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 5cfaa47580b59..f660af9bba8ad 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -87,7 +87,6 @@ SERV_ACCESSORY_INFO = "AccessoryInformation" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" SERV_BATTERY_SERVICE = "BatteryService" -SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" @@ -99,7 +98,6 @@ SERV_LIGHT_SENSOR = "LightSensor" SERV_LIGHTBULB = "Lightbulb" SERV_LOCK = "LockMechanism" -SERV_MICROPHONE = "Microphone" SERV_MOTION_SENSOR = "MotionSensor" SERV_OCCUPANCY_SENSOR = "OccupancySensor" SERV_OUTLET = "Outlet" @@ -164,16 +162,10 @@ CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" CHAR_SATURATION = "Saturation" -CHAR_SELECTED_RTP_STREAM_CONFIGURATION = "SelectedRTPStreamConfiguration" CHAR_SERIAL_NUMBER = "SerialNumber" -CHAR_SETUP_ENDPOINTS = "SetupEndpoints" CHAR_SLEEP_DISCOVER_MODE = "SleepDiscoveryMode" CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" -CHAR_STREAMING_STATUS = "StreamingStatus" -CHAR_SUPPORTED_AUDIO_STREAM_CONFIGURATION = "SupportedAudioStreamConfiguration" -CHAR_SUPPORTED_RTP_CONFIGURATION = "SupportedRTPConfiguration" -CHAR_SUPPORTED_VIDEO_STREAM_CONFIGURATION = "SupportedVideoStreamConfiguration" CHAR_SWING_MODE = "SwingMode" CHAR_TARGET_DOOR_STATE = "TargetDoorState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" From ba77b87a13afab962abab50745b1daf9b8d47bf5 Mon Sep 17 00:00:00 2001 From: Greg Thornton Date: Fri, 10 Apr 2020 23:58:30 -0500 Subject: [PATCH 06/28] Fix lint errors in homekit camera --- homeassistant/components/homekit/type_cameras.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 6abc7b82ba0e3..5f486fca67595 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -151,7 +151,7 @@ def __init__(self, hass, driver, name, entity_id, aid, config): def update_state(self, new_state): """Handle state change to update HomeKit value.""" - pass + pass # pylint: disable=unnecessary-pass def _get_stream_source(self): camera = self._camera.get_entity(self.entity_id) @@ -164,7 +164,7 @@ def _get_stream_source(self): stream_source = asyncio.run_coroutine_threadsafe( camera.stream_source(), self.hass.loop ).result(10) - except Exception as err: + except Exception as err: # pylint: disable=broad-except _LOGGER.error("Failed to get stream source: %s", err) return stream_source From 752acd8810770ded0b7d51e7932c70832b002efd Mon Sep 17 00:00:00 2001 From: Greg Thornton Date: Thu, 16 Apr 2020 03:55:52 -0500 Subject: [PATCH 07/28] Update homekit camera log messages --- homeassistant/components/homekit/type_cameras.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 5f486fca67595..a93b357688b21 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -164,8 +164,10 @@ def _get_stream_source(self): stream_source = asyncio.run_coroutine_threadsafe( camera.stream_source(), self.hass.loop ).result(10) - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Failed to get stream source: %s", err) + 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" + ) return stream_source async def start_stream(self, session_info, stream_config): @@ -232,7 +234,7 @@ async def stop_stream(self, session_info): await stream.close() _LOGGER.debug("Stream process stopped.") else: - _LOGGER.warning("No stream for session ID %s", session_id) + _LOGGER.debug("No stream for session ID %s", session_id) async def reconfigure_stream(self, session_info, stream_config): """Reconfigure the stream so that it uses the given ``stream_config``.""" From 5015d7cd684e96c77b1e7ef0ec38a7b2b1e49ae8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 19:54:15 +0000 Subject: [PATCH 08/28] Black after conflict fixes --- homeassistant/components/homekit/__init__.py | 3 +-- homeassistant/components/homekit/util.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 41c200414ad86..d9ce432835b88 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -344,14 +344,13 @@ def async_describe_logbook_event(event): value_msg = f" to {value}" if value else "" message = f"send command {data[ATTR_SERVICE]}{value_msg} for {data[ATTR_DISPLAY_NAME]}" - + return { "name": "HomeKit", "message": message, "entity_id": entity_id, } - hass.components.logbook.async_describe_event( DOMAIN, EVENT_HOMEKIT_CHANGED, async_describe_logbook_event ) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index a613ca2d17ad6..20053b2507c02 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -41,12 +41,12 @@ DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_PACKET_SIZE, DEFAULT_LOW_BATTERY_THRESHOLD, - DOMAIN, DEFAULT_MAX_FPS, DEFAULT_MAX_HEIGHT, DEFAULT_MAX_WIDTH, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, From 62bcb4d78ed14d3ddce23d2484168c234c5de9d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 19:57:03 +0000 Subject: [PATCH 09/28] More conflict fixes --- homeassistant/components/homekit/type_cameras.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index a93b357688b21..24b7a7bb83e1f 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -15,8 +15,7 @@ from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.util import get_local_ip -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import ( CONF_AUDIO_MAP, CONF_AUDIO_PACKET_SIZE, From 20e6e31452df9aecc540e3eb21e85687b3311583 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 21:52:42 +0000 Subject: [PATCH 10/28] missing srtp --- homeassistant/components/homekit/type_cameras.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 24b7a7bb83e1f..52680405e8165 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -135,6 +135,7 @@ def __init__(self, hass, driver, name, entity_id, aid, config): "video": video_options, "audio": audio_options, "address": stream_address, + "srtp": True, } super().__init__( From 960b2182a57ea50245f8024b475d1a1288ef4201 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 22:16:32 +0000 Subject: [PATCH 11/28] Allow streaming retry when ffmpeg fails to connect --- homeassistant/components/homekit/type_cameras.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 52680405e8165..335bdfcf92252 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -231,7 +231,10 @@ async def stop_stream(self, session_info): stream = session_info.get("stream") if stream: _LOGGER.info("[%s] Stopping stream.", session_id) - await stream.close() + try: + await stream.close() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failed to cleanly close stream.") _LOGGER.debug("Stream process stopped.") else: _LOGGER.debug("No stream for session ID %s", session_id) From 1eafe277642931369205829920cdf39b8c5520f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 22:41:07 +0000 Subject: [PATCH 12/28] Fix inherit of camera config, force kill ffmpeg on failure --- .../components/homekit/type_cameras.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 335bdfcf92252..0d3e04a2975f3 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -91,12 +91,11 @@ 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) - self.config = CAMERA_SCHEMA(config) - - max_fps = self.config[CONF_MAX_FPS] - max_width = self.config[CONF_MAX_WIDTH] - max_height = self.config[CONF_MAX_HEIGHT] + 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 @@ -129,7 +128,7 @@ def __init__(self, hass, driver, name, entity_id, aid, config): ] } - stream_address = self.config.get(CONF_STREAM_ADDRESS) or get_local_ip() + stream_address = config_w_defaults.get(CONF_STREAM_ADDRESS, get_local_ip()) options = { "video": video_options, @@ -144,7 +143,7 @@ def __init__(self, hass, driver, name, entity_id, aid, config): name, entity_id, aid, - config, + config_w_defaults, category=CATEGORY_CAMERA, options=options, ) @@ -231,10 +230,17 @@ async def stop_stream(self, session_info): stream = session_info.get("stream") if stream: _LOGGER.info("[%s] Stopping stream.", session_id) + close_ok = False try: await stream.close() + close_ok = True except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to cleanly close stream.") + if not close_ok: + try: + await stream.kill() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failed to forcefully close stream.") _LOGGER.debug("Stream process stopped.") else: _LOGGER.debug("No stream for session ID %s", session_id) From 54310a41481ab35b6ac69d8ac9602155f488cc87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 23:39:55 +0000 Subject: [PATCH 13/28] Fix audio (Home Assistant only comes with OPUS) --- homeassistant/components/homekit/type_cameras.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 0d3e04a2975f3..8264ca1a59e46 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -4,7 +4,6 @@ from haffmpeg.core import HAFFmpeg from pyhap.camera import ( - AUDIO_CODEC_TYPES, VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, Camera as PyhapCamera, @@ -121,12 +120,7 @@ def __init__(self, hass, driver, name, entity_id, aid, config): }, "resolutions": resolutions, } - audio_options = { - "codecs": [ - {"type": "OPUS", "samplerate": 24}, - {"type": "AAC-eld", "samplerate": 16}, - ] - } + audio_options = {"codecs": [{"type": "OPUS", "samplerate": 24}]} stream_address = config_w_defaults.get(CONF_STREAM_ADDRESS, get_local_ip()) @@ -197,16 +191,13 @@ async def start_stream(self, session_info, stream_config): "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 - if stream_config["a_codec"] == AUDIO_CODEC_TYPES["OPUS"] - else AUDIO_ENCODER_AAC - ), + "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( From 020ce3658991fce8d2e2bd4ea33922055b0d5498 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 23:42:59 +0000 Subject: [PATCH 14/28] Fix audio (Home Assistant only comes with OPUS) --- homeassistant/components/homekit/type_cameras.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 8264ca1a59e46..2ed470b2a7297 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -46,8 +46,6 @@ AUDIO_ENCODER_OPUS = "libopus -application lowdelay" -AUDIO_ENCODER_AAC = "libfdk_aac -profile:a aac_eld -flags +global_header" - AUDIO_OUTPUT = ( "-map {a_map} -vn " "-c:a {a_encoder} " From 933fed85a94f4309dc683d89055248074a7e689d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 23:54:39 +0000 Subject: [PATCH 15/28] Add camera to the list of supported domains. --- homeassistant/components/homekit/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 0f83b7a3c24fa..039b0ef063ad5 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -38,6 +38,7 @@ "alarm_control_panel", "automation", "binary_sensor", + "camera", "climate", "cover", "demo", From 815e5d7667da11a27ec7f3d186fe6689b61a705a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2020 21:19:04 +0000 Subject: [PATCH 16/28] add a test for camera creation --- tests/components/homekit/test_get_accessories.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 286fe51535e6a..8a9cfb18dda3f 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -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 From 3e13e221a08272b152798dad6602e1088a737583 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2020 21:39:24 +0000 Subject: [PATCH 17/28] Add a basic test (still needs more as its only at 44% cover) --- tests/components/homekit/test_type_cameras.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/components/homekit/test_type_cameras.py diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py new file mode 100644 index 0000000000000..736da7736c42d --- /dev/null +++ b/tests/components/homekit/test_type_cameras.py @@ -0,0 +1,21 @@ +"""Test different accessory types: Camera.""" + +from homeassistant.components import camera, ffmpeg +from homeassistant.components.homekit.type_cameras import Camera +from homeassistant.setup import async_setup_component + + +async def test_camera_stream(hass, hk_driver, events): + """Test if accessory and HA are updated accordingly.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component(hass, camera.DOMAIN, {camera.DOMAIN: {}}) + + entity_id = "camera.kitchen_door" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Camera(hass, hk_driver, "Camera", entity_id, 2, {}) + await acc.run_handler() + + assert acc.aid == 2 + assert acc.category == 17 # Camera From 58fb34c80dfba1ca1215e03ff4e599c3b1458165 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2020 22:30:22 +0000 Subject: [PATCH 18/28] let super handle reconfigure_stream --- homeassistant/components/homekit/type_cameras.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 2ed470b2a7297..4f7c5f9b564fb 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -234,10 +234,6 @@ async def stop_stream(self, session_info): else: _LOGGER.debug("No stream for session ID %s", session_id) - 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 ( From d2cb48ed18a6469d95398ebc4054aed40a646fd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 May 2020 00:38:01 +0000 Subject: [PATCH 19/28] Remove scaling as it does not appear to be needed and causes artifacts --- .../components/homekit/type_cameras.py | 59 +++++-------------- tests/components/homekit/test_type_cameras.py | 15 +++++ 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 4f7c5f9b564fb..2ebaa297a0fc0 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -2,7 +2,6 @@ import asyncio import logging -from haffmpeg.core import HAFFmpeg from pyhap.camera import ( VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, @@ -35,7 +34,6 @@ "-map {v_map} -an " "-c:v libx264 -profile:v {v_profile} -tune zerolatency -pix_fmt yuv420p " "-r {fps} " - "-vf 'scale=min({width}\\,iw):min({height}\\,ih):force_original_aspect_ratio=increase' " "-b:v {v_max_bitrate}k -bufsize {v_bufsize}k -maxrate {v_max_bitrate}k " "-payload_type 99 " "-ssrc {v_ssrc} -f rtp " @@ -144,7 +142,8 @@ def update_state(self, new_state): """Handle state change to update HomeKit value.""" pass # pylint: disable=unnecessary-pass - def _get_stream_source(self): + 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 @@ -152,14 +151,11 @@ def _get_stream_source(self): if stream_source: return stream_source try: - stream_source = asyncio.run_coroutine_threadsafe( - camera.stream_source(), self.hass.loop - ).result(10) + 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" ) - return stream_source async def start_stream(self, session_info, stream_config): """Start a new stream with the given configuration.""" @@ -169,7 +165,7 @@ async def start_stream(self, session_info, stream_config): stream_config, ) - input_source = self._get_stream_source() + input_source = await self._async_get_stream_source() if not input_source: _LOGGER.error("Camera has no stream source") return False @@ -192,47 +188,22 @@ async def start_stream(self, session_info, stream_config): "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 + self.start_stream_cmd = f"{self._ffmpeg.binary} {input_source} {VIDEO_OUTPUT}" + if self.config[CONF_SUPPORT_AUDIO]: + self.start_stream_cmd += " " + AUDIO_OUTPUT - session_info["stream"] = stream - _LOGGER.info( - "[%s] Started stream process - PID %d", - session_info["id"], - stream.process.pid, - ) - return True + return await super().start_stream(session_info, output_vars) 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 stream: - _LOGGER.info("[%s] Stopping stream.", session_id) - close_ok = False - try: - await stream.close() - close_ok = True - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Failed to cleanly close stream.") - if not close_ok: - try: - await stream.kill() - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Failed to forcefully close stream.") - _LOGGER.debug("Stream process stopped.") - else: - _LOGGER.debug("No stream for session ID %s", session_id) + # If we do not trap the exception from an + # unclean shutdown they cannot resume streaming + # again until restart + try: + await super().stop_stream(session_info) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failed to cleanly close stream.") def get_snapshot(self, image_size): """Return a jpeg of a snapshot from the camera.""" diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 736da7736c42d..7a130d6597331 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -19,3 +19,18 @@ async def test_camera_stream(hass, hk_driver, events): 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 + endpoints_config_char.set_value( + "ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA==" + ) + stream_config_char = stream_service.get_characteristic( + "SelectedRTPStreamConfiguration" + ) + assert stream_config_char.setter_callback + stream_config_char.set_value( + "ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA" + ) + await hass.async_block_till_done() From 13ebe924a8f99091ab58a44d21028caa6c392878 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 May 2020 02:38:03 +0000 Subject: [PATCH 20/28] Some more basic tests --- .../components/homekit/type_cameras.py | 8 +- tests/components/homekit/test_type_cameras.py | 107 ++++++++++++++++-- 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 2ebaa297a0fc0..50ea8ef54cc2e 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -159,12 +159,6 @@ async def _async_get_stream_source(self): 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") @@ -201,7 +195,7 @@ async def stop_stream(self, session_info): # unclean shutdown they cannot resume streaming # again until restart try: - await super().stop_stream(session_info) + return await super().stop_stream(session_info) except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to cleanly close stream.") diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 7a130d6597331..b23a295cd0fdb 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -1,20 +1,55 @@ """Test different accessory types: Camera.""" +from uuid import UUID + +from asynctest import patch +from pyhap.accessory_driver import AccessoryDriver +import pytest + from homeassistant.components import camera, ffmpeg +from homeassistant.components.homekit.const import ( + CONF_STREAM_SOURCE, + CONF_SUPPORT_AUDIO, +) from homeassistant.components.homekit.type_cameras import Camera +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -async def test_camera_stream(hass, hk_driver, events): - """Test if accessory and HA are updated accordingly.""" +@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 + ) + + +async def test_camera_stream(hass, run_driver, events): + """Test a camera that can stream.""" await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - await async_setup_component(hass, camera.DOMAIN, {camera.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) - entity_id = "camera.kitchen_door" + entity_id = "camera.demo_camera" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = Camera(hass, hk_driver, "Camera", entity_id, 2, {}) + acc = Camera( + hass, + run_driver, + "Camera", + entity_id, + 2, + {CONF_STREAM_SOURCE: "/dev/null", CONF_SUPPORT_AUDIO: True}, + ) await acc.run_handler() assert acc.aid == 2 @@ -23,14 +58,68 @@ async def test_camera_stream(hass, hk_driver, events): stream_service = acc.get_service("CameraRTPStreamManagement") endpoints_config_char = stream_service.get_characteristic("SetupEndpoints") assert endpoints_config_char.setter_callback - endpoints_config_char.set_value( - "ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA==" - ) stream_config_char = stream_service.get_characteristic( "SelectedRTPStreamConfiguration" ) assert stream_config_char.setter_callback - stream_config_char.set_value( + acc.set_endpoints( + "ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA==" + ) + + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value=None, + ): + 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", + ): + acc.set_selected_stream_configuration( + "ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA" + ) + await acc.stop_stream(session_info) + await hass.async_block_till_done() + + assert await hass.async_add_executor_job(acc.get_snapshot, 1024) + + +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) From 7d96b62b366c253ea6e2ec5461a9ed91c8941c68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 May 2020 03:52:29 +0000 Subject: [PATCH 21/28] make sure no exceptions when finding the source from the entity --- tests/components/homekit/test_type_cameras.py | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index b23a295cd0fdb..693f355f6404b 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -31,8 +31,8 @@ def run_driver(hass): ) -async def test_camera_stream(hass, run_driver, events): - """Test a camera that can stream.""" +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"}} @@ -98,6 +98,55 @@ async def test_camera_stream(hass, run_driver, events): assert await hass.async_add_executor_job(acc.get_snapshot, 1024) +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", + ): + 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: {}}) From 1c7fc1781e3a0083aa1fb667041f3534648ef61d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 May 2020 04:53:16 +0000 Subject: [PATCH 22/28] make sure the bridge forwards get_snapshot --- tests/components/homekit/test_type_cameras.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 693f355f6404b..7b361851108f6 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -7,6 +7,7 @@ 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, @@ -50,6 +51,8 @@ async def test_camera_stream_source_configured(hass, run_driver, events): 2, {CONF_STREAM_SOURCE: "/dev/null", CONF_SUPPORT_AUDIO: True}, ) + bridge = HomeBridge("hass", run_driver, "Test Bridge") + bridge.add_accessory(acc) await acc.run_handler() assert acc.aid == 2 @@ -96,6 +99,7 @@ async def test_camera_stream_source_configured(hass, run_driver, events): await hass.async_block_till_done() assert await hass.async_add_executor_job(acc.get_snapshot, 1024) + assert await hass.async_add_executor_job(bridge.get_snapshot, {"aid": 2}) async def test_camera_stream_source_found(hass, run_driver, events): From a52b784423d351fa7996bc8837a93054d5304c93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 May 2020 04:58:59 +0000 Subject: [PATCH 23/28] restore full coverage to accessories.py --- tests/components/homekit/test_type_cameras.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 7b361851108f6..9af640b74697d 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -13,6 +13,7 @@ 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 @@ -51,8 +52,11 @@ async def test_camera_stream_source_configured(hass, run_driver, events): 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 @@ -99,7 +103,14 @@ async def test_camera_stream_source_configured(hass, run_driver, events): 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_found(hass, run_driver, events): From a8d052b01d6d9a404dc34244970ea478e77455f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 May 2020 14:09:34 +0000 Subject: [PATCH 24/28] revert usage of super for start/stop stream --- .../components/homekit/type_cameras.py | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 50ea8ef54cc2e..0d26e36c5d9bb 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -2,6 +2,7 @@ import asyncio import logging +from haffmpeg.core import HAFFmpeg from pyhap.camera import ( VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, @@ -159,14 +160,17 @@ async def _async_get_stream_source(self): 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( { @@ -182,22 +186,44 @@ async def start_stream(self, session_info, stream_config): "a_encoder": AUDIO_ENCODER_OPUS, } ) - - self.start_stream_cmd = f"{self._ffmpeg.binary} {input_source} {VIDEO_OUTPUT}" + output = VIDEO_OUTPUT.format(**output_vars) if self.config[CONF_SUPPORT_AUDIO]: - self.start_stream_cmd += " " + AUDIO_OUTPUT - - return await super().start_stream(session_info, output_vars) + 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``.""" - # If we do not trap the exception from an - # unclean shutdown they cannot resume streaming - # again until restart + 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) + close_ok = False try: - return await super().stop_stream(session_info) + await stream.close() + close_ok = True except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to cleanly close stream.") + if not close_ok: + try: + await stream.kill() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failed to forcefully close stream.") + _LOGGER.debug("Stream process stopped.") def get_snapshot(self, image_size): """Return a jpeg of a snapshot from the camera.""" From a84259dbbf1094dea460567a84d1f6a13677c3e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 May 2020 14:15:32 +0000 Subject: [PATCH 25/28] one more test --- .../components/homekit/type_cameras.py | 18 +++++++++--------- tests/components/homekit/test_type_cameras.py | 2 ++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 0d26e36c5d9bb..e40f03d932e1e 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -212,18 +212,18 @@ async def stop_stream(self, session_info): if not stream: _LOGGER.debug("No stream for session ID %s", session_id) _LOGGER.info("[%s] Stopping stream.", session_id) - close_ok = False + try: await stream.close() - close_ok = True + 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 cleanly close stream.") - if not close_ok: - try: - await stream.kill() - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Failed to forcefully close stream.") - _LOGGER.debug("Stream process stopped.") + _LOGGER.exception("Failed to forcefully close stream.") + _LOGGER.debug("Stream process stopped forcefully.") def get_snapshot(self, image_size): """Return a jpeg of a snapshot from the camera.""" diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 9af640b74697d..8678133f958c0 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -100,6 +100,8 @@ async def test_camera_stream_source_configured(hass, run_driver, events): "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) From 854f26e194053adade1e704901651796d558a853 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 May 2020 14:47:35 +0000 Subject: [PATCH 26/28] more mocking --- tests/components/homekit/test_type_cameras.py | 163 +++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 8678133f958c0..97716df3f1e89 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -2,7 +2,6 @@ from uuid import UUID -from asynctest import patch from pyhap.accessory_driver import AccessoryDriver import pytest @@ -17,6 +16,8 @@ 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): @@ -33,6 +34,24 @@ def run_driver(hass): ) +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: {}}) @@ -76,6 +95,9 @@ async def test_camera_stream_source_configured(hass, run_driver, events): 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" @@ -95,6 +117,9 @@ async def test_camera_stream_source_configured(hass, run_driver, events): 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" @@ -115,6 +140,74 @@ async def test_camera_stream_source_configured(hass, run_driver, events): 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: {}}) @@ -156,6 +249,74 @@ async def test_camera_stream_source_found(hass, run_driver, events): 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" From 2820afccdf1bac18e8d264149c012c40ca448137 Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Mon, 4 May 2020 22:49:01 +0200 Subject: [PATCH 27/28] video codec support added --- homeassistant/components/homekit/const.py | 2 ++ .../components/homekit/type_cameras.py | 19 +++++++++++++++---- homeassistant/components/homekit/util.py | 3 +++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index f660af9bba8ad..7da73d4ebed9a 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -39,6 +39,7 @@ CONF_SUPPORT_AUDIO = "support_audio" CONF_VIDEO_MAP = "video_map" CONF_VIDEO_PACKET_SIZE = "video_packet_size" +CONF_VIDEO_CODEC = "video_codec" # #### Config Defaults #### DEFAULT_AUDIO_MAP = "0:a:0" @@ -54,6 +55,7 @@ DEFAULT_ZEROCONF_DEFAULT_INTERFACE = False DEFAULT_VIDEO_MAP = "0:v:0" DEFAULT_VIDEO_PACKET_SIZE = 1316 +DEFAULT_VIDEO_CODEC = "libx264" # #### Features #### FEATURE_ON_OFF = "on_off" diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index e40f03d932e1e..4f94079a6e977 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -24,6 +24,7 @@ CONF_STREAM_ADDRESS, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, + CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, ) @@ -33,7 +34,9 @@ VIDEO_OUTPUT = ( "-map {v_map} -an " - "-c:v libx264 -profile:v {v_profile} -tune zerolatency -pix_fmt yuv420p " + "-vcodec {v_codec} " + "{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 " @@ -171,15 +174,23 @@ async def start_stream(self, session_info, stream_config): return False if "-i " not in input_source: input_source = "-i " + input_source + video_profile = "" + if self.config[CONF_VIDEO_CODEC] != "copy": + video_profile = ( + "-profile:v " + + VIDEO_PROFILE_NAMES[ + int.from_bytes(stream_config["v_profile_id"], byteorder="big") + ] + + " " + ) output_vars = stream_config.copy() output_vars.update( { - "v_profile": VIDEO_PROFILE_NAMES[ - int.from_bytes(stream_config["v_profile_id"], byteorder="big") - ], + "v_profile": video_profile, "v_bufsize": stream_config["v_max_bitrate"] * 2, "v_map": self.config[CONF_VIDEO_MAP], "v_pkt_size": self.config[CONF_VIDEO_PACKET_SIZE], + "v_codec": self.config[CONF_VIDEO_CODEC], "a_bufsize": stream_config["a_max_bitrate"] * 2, "a_map": self.config[CONF_AUDIO_MAP], "a_pkt_size": self.config[CONF_AUDIO_PACKET_SIZE], diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 20053b2507c02..da836eb74ae08 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -36,6 +36,7 @@ CONF_STREAM_ADDRESS, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, + CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, DEFAULT_AUDIO_MAP, @@ -44,6 +45,7 @@ DEFAULT_MAX_FPS, DEFAULT_MAX_HEIGHT, DEFAULT_MAX_WIDTH, + DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, DOMAIN, @@ -90,6 +92,7 @@ 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_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): cv.string, vol.Optional( CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE ): cv.positive_int, From 090648df7b75d8758a667121b25fa24e56176774 Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Mon, 4 May 2020 22:53:52 +0200 Subject: [PATCH 28/28] revert back to -c:v --- homeassistant/components/homekit/type_cameras.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 4f94079a6e977..6486108f480fc 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -34,7 +34,7 @@ VIDEO_OUTPUT = ( "-map {v_map} -an " - "-vcodec {v_codec} " + "-c:v {v_codec} " "{v_profile}" "-tune zerolatency -pix_fmt yuv420p " "-r {fps} "