diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 428f8e30abf..5814fdaad2b 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -9,8 +9,14 @@ import voluptuous as vol from zeroconf import InterfaceChoice from homeassistant.components import zeroconf -from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_MOTION, + DOMAIN as BINARY_SENSOR_DOMAIN, +) +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.http import HomeAssistantView +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, @@ -58,6 +64,7 @@ from .const import ( CONF_FILTER, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_MOTION_SENSOR, CONF_SAFE_MODE, CONF_ZEROCONF_DEFAULT_INTERFACE, CONFIG_OPTIONS, @@ -522,8 +529,9 @@ class HomeKit: device_lookup = ent_reg.async_get_device_class_lookup( { - ("binary_sensor", DEVICE_CLASS_BATTERY_CHARGING), - ("sensor", DEVICE_CLASS_BATTERY), + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING), + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION), + (SENSOR_DOMAIN, DEVICE_CLASS_BATTERY), } ) @@ -537,9 +545,7 @@ class HomeKit: await self._async_set_device_info_attributes( ent_reg_ent, dev_reg, state.entity_id ) - self._async_configure_linked_battery_sensors( - ent_reg_ent, device_lookup, state - ) + self._async_configure_linked_sensors(ent_reg_ent, device_lookup, state) bridged_states.append(state) @@ -629,9 +635,7 @@ class HomeKit: self.hass.add_job(self.driver.stop) @callback - def _async_configure_linked_battery_sensors( - self, ent_reg_ent, device_lookup, state - ): + def _async_configure_linked_sensors(self, ent_reg_ent, device_lookup, state): if ( ent_reg_ent is None or ent_reg_ent.device_id is None @@ -644,7 +648,7 @@ class HomeKit: if ATTR_BATTERY_CHARGING not in state.attributes: battery_charging_binary_sensor_entity_id = device_lookup[ ent_reg_ent.device_id - ].get(("binary_sensor", DEVICE_CLASS_BATTERY_CHARGING)) + ].get((BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING)) if battery_charging_binary_sensor_entity_id: self._config.setdefault(state.entity_id, {}).setdefault( CONF_LINKED_BATTERY_CHARGING_SENSOR, @@ -653,13 +657,22 @@ class HomeKit: if ATTR_BATTERY_LEVEL not in state.attributes: battery_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( - ("sensor", DEVICE_CLASS_BATTERY) + (SENSOR_DOMAIN, DEVICE_CLASS_BATTERY) ) if battery_sensor_entity_id: self._config.setdefault(state.entity_id, {}).setdefault( CONF_LINKED_BATTERY_SENSOR, battery_sensor_entity_id ) + if state.entity_id.startswith(f"{CAMERA_DOMAIN}."): + motion_binary_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION) + ) + if motion_binary_sensor_entity_id: + self._config.setdefault(state.entity_id, {}).setdefault( + CONF_LINKED_MOTION_SENSOR, motion_binary_sensor_entity_id, + ) + async def _async_set_device_info_attributes(self, ent_reg_ent, dev_reg, entity_id): """Set attributes that will be used for homekit device info.""" ent_cfg = self._config.setdefault(entity_id, {}) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 8c431830589..2a93ae4cf7e 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -41,6 +41,7 @@ CONF_FEATURE_LIST = "feature_list" CONF_FILTER = "filter" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" +CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index c81790a3874..e25c5189262 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -13,16 +13,22 @@ from pyhap.camera import ( from pyhap.const import CATEGORY_CAMERA from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import STATE_ON from homeassistant.core import callback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import ( + async_track_state_change, + async_track_time_interval, +) from homeassistant.util import get_local_ip from .accessories import TYPES, HomeAccessory from .const import ( + CHAR_MOTION_DETECTED, CHAR_STREAMING_STRATUS, CONF_AUDIO_CODEC, CONF_AUDIO_MAP, CONF_AUDIO_PACKET_SIZE, + CONF_LINKED_MOTION_SENSOR, CONF_MAX_FPS, CONF_MAX_HEIGHT, CONF_MAX_WIDTH, @@ -43,6 +49,7 @@ from .const import ( DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, SERV_CAMERA_RTP_STREAM_MANAGEMENT, + SERV_MOTION_SENSOR, ) from .img_util import scale_jpeg_camera_image from .util import pid_is_alive @@ -178,6 +185,47 @@ class Camera(HomeAccessory, PyhapCamera): category=CATEGORY_CAMERA, options=options, ) + self._char_motion_detected = None + self.linked_motion_sensor = self.config.get(CONF_LINKED_MOTION_SENSOR) + if not self.linked_motion_sensor: + return + state = self.hass.states.get(self.linked_motion_sensor) + if not state: + return + serv_motion = self.add_preload_service(SERV_MOTION_SENSOR) + self._char_motion_detected = serv_motion.configure_char( + CHAR_MOTION_DETECTED, value=False + ) + self._async_update_motion_state(None, None, state) + + async def run_handler(self): + """Handle accessory driver started event. + + Run inside the Home Assistant event loop. + """ + if self._char_motion_detected: + async_track_state_change( + self.hass, self.linked_motion_sensor, self._async_update_motion_state + ) + + await super().run_handler() + + @callback + def _async_update_motion_state( + self, entity_id=None, old_state=None, new_state=None + ): + """Handle link motion sensor state change to update HomeKit value.""" + detected = new_state.state == STATE_ON + if self._char_motion_detected.value == detected: + return + + self._char_motion_detected.set_value(detected) + _LOGGER.debug( + "%s: Set linked motion %s sensor to %d", + self.entity_id, + self.linked_motion_sensor, + detected, + ) @callback def async_update_state(self, new_state): diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index d35c463ca39..0465e33388d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -11,7 +11,7 @@ import socket import pyqrcode import voluptuous as vol -from homeassistant.components import fan, media_player, sensor +from homeassistant.components import binary_sensor, fan, media_player, sensor from homeassistant.const import ( ATTR_CODE, ATTR_SUPPORTED_FEATURES, @@ -32,7 +32,9 @@ from .const import ( CONF_AUDIO_PACKET_SIZE, CONF_FEATURE, CONF_FEATURE_LIST, + CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_MOTION_SENSOR, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -83,6 +85,9 @@ BASIC_INFO_SCHEMA = vol.Schema( { vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_LINKED_BATTERY_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_BATTERY_CHARGING_SENSOR): cv.entity_domain( + binary_sensor.DOMAIN + ), vol.Optional( CONF_LOW_BATTERY_THRESHOLD, default=DEFAULT_LOW_BATTERY_THRESHOLD ): cv.positive_int, @@ -115,6 +120,7 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( vol.Optional( CONF_VIDEO_PACKET_SIZE, default=DEFAULT_VIDEO_PACKET_SIZE ): cv.positive_int, + vol.Optional(CONF_LINKED_MOTION_SENSOR): cv.entity_domain(binary_sensor.DOMAIN), } ) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index b016997b7c9..5e756ae51b5 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -6,7 +6,10 @@ import pytest from zeroconf import InterfaceChoice from homeassistant.components import zeroconf -from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_MOTION, +) from homeassistant.components.homekit import ( MAX_DEVICES, STATUS_READY, @@ -1032,3 +1035,78 @@ async def test_homekit_ignored_missing_devices( "linked_battery_sensor": "sensor.powerwall_battery", }, ) + + +async def test_homekit_finds_linked_motion_sensors( + hass, hk_driver, debounce_patcher, device_reg, entity_reg +): + """Test HomeKit start method.""" + entry = await async_init_integration(hass) + + homekit = HomeKit( + hass, + None, + None, + None, + {}, + {"camera.camera_demo": {}}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) + homekit.driver = hk_driver + homekit._filter = Mock(return_value=True) + homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + sw_version="0.16.0", + model="Camera Server", + manufacturer="Ubq", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + binary_motion_sensor = entity_reg.async_get_or_create( + "binary_sensor", + "camera", + "motion_sensor", + device_id=device_entry.id, + device_class=DEVICE_CLASS_MOTION, + ) + camera = entity_reg.async_get_or_create( + "camera", "camera", "demo", device_id=device_entry.id + ) + + hass.states.async_set( + binary_motion_sensor.entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION}, + ) + hass.states.async_set(camera.entity_id, STATE_ON) + + def _mock_get_accessory(*args, **kwargs): + return [None, "acc", None] + + with patch.object(homekit.bridge, "add_accessory"), patch( + f"{PATH_HOMEKIT}.show_setup_message" + ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( + "pyhap.accessory_driver.AccessoryDriver.start" + ): + await homekit.async_start() + await hass.async_block_till_done() + + mock_get_acc.assert_called_with( + hass, + hk_driver, + ANY, + ANY, + { + "manufacturer": "Ubq", + "model": "Camera Server", + "sw_version": "0.16.0", + "linked_motion_sensor": "binary_sensor.camera_motion_sensor", + }, + ) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 0c002fa7213..9de08b8fc22 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -9,16 +9,21 @@ from homeassistant.components import camera, ffmpeg from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( AUDIO_CODEC_COPY, + CHAR_MOTION_DETECTED, CONF_AUDIO_CODEC, + CONF_LINKED_MOTION_SENSOR, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, + DEVICE_CLASS_MOTION, + SERV_MOTION_SENSOR, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, ) from homeassistant.components.homekit.img_util import TurboJPEGSingleton from homeassistant.components.homekit.type_cameras import Camera from homeassistant.components.homekit.type_switches import Switch +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -492,3 +497,91 @@ async def test_camera_streaming_fails_after_starting_ffmpeg(hass, run_driver, ev output=expected_output.format(**session_info), stdout_pipe=False, ) + + +async def test_camera_with_linked_motion_sensor(hass, run_driver, events): + """Test a camera with a linked motion sensor can update.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + motion_entity_id = "binary_sensor.motion" + + hass.states.async_set( + motion_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + ) + await hass.async_block_till_done() + 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, + CONF_VIDEO_CODEC: VIDEO_CODEC_H264_OMX, + CONF_AUDIO_CODEC: AUDIO_CODEC_COPY, + CONF_LINKED_MOTION_SENSOR: motion_entity_id, + }, + ) + bridge = HomeBridge("hass", run_driver, "Test Bridge") + bridge.add_accessory(acc) + + await acc.run_handler() + + assert acc.aid == 2 + assert acc.category == 17 # Camera + + service = acc.get_service(SERV_MOTION_SENSOR) + assert service + char = service.get_characteristic(CHAR_MOTION_DETECTED) + assert char + + assert char.value is True + + hass.states.async_set( + motion_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + ) + await hass.async_block_till_done() + assert char.value is False + + char.set_value(True) + hass.states.async_set( + motion_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + ) + await hass.async_block_till_done() + assert char.value is True + + +async def test_camera_with_a_missing_linked_motion_sensor(hass, run_driver, events): + """Test a camera with a configured linked motion sensor that is missing.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + motion_entity_id = "binary_sensor.motion" + 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_LINKED_MOTION_SENSOR: motion_entity_id}, + ) + bridge = HomeBridge("hass", run_driver, "Test Bridge") + bridge.add_accessory(acc) + + await acc.run_handler() + + assert acc.aid == 2 + assert acc.category == 17 # Camera + + assert not acc.get_service(SERV_MOTION_SENSOR)