diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 494daa54b23..2b6db6af528 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -11,6 +11,7 @@ from homeassistant.components import zeroconf from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, DOMAIN as BINARY_SENSOR_DOMAIN, ) from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN @@ -55,6 +56,7 @@ from .const import ( CONF_FILTER, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_SAFE_MODE, @@ -487,6 +489,7 @@ class HomeKit: { (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING), (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION), + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY), (SENSOR_DOMAIN, DEVICE_CLASS_BATTERY), (SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY), } @@ -631,6 +634,13 @@ class HomeKit: self._config.setdefault(state.entity_id, {}).setdefault( CONF_LINKED_MOTION_SENSOR, motion_binary_sensor_entity_id, ) + doorbell_binary_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY) + ) + if doorbell_binary_sensor_entity_id: + self._config.setdefault(state.entity_id, {}).setdefault( + CONF_LINKED_DOORBELL_SENSOR, doorbell_binary_sensor_entity_id, + ) if state.entity_id.startswith(f"{HUMIDIFIER_DOMAIN}."): current_humidity_sensor_entity_id = device_lookup[ diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index b32d7f4bad4..e38b86a7032 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_DOORBELL_SENSOR = "linked_doorbell_sensor" CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" @@ -112,6 +113,7 @@ SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" +SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" @@ -126,6 +128,7 @@ SERV_OCCUPANCY_SENSOR = "OccupancySensor" SERV_OUTLET = "Outlet" SERV_SECURITY_SYSTEM = "SecuritySystem" SERV_SMOKE_SENSOR = "SmokeSensor" +SERV_SPEAKER = "Speaker" SERV_SWITCH = "Switch" SERV_TELEVISION = "Television" SERV_TELEVISION_SPEAKER = "TelevisionSpeaker" @@ -184,6 +187,7 @@ CHAR_OCCUPANCY_DETECTED = "OccupancyDetected" CHAR_ON = "On" CHAR_OUTLET_IN_USE = "OutletInUse" CHAR_POSITION_STATE = "PositionState" +CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent" CHAR_REMOTE_KEY = "RemoteKey" CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 629e1019f4a..93b822f9e7a 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -23,9 +23,12 @@ from homeassistant.util import get_local_ip from .accessories import TYPES, HomeAccessory from .const import ( CHAR_MOTION_DETECTED, + CHAR_MUTE, + CHAR_PROGRAMMABLE_SWITCH_EVENT, CONF_AUDIO_CODEC, CONF_AUDIO_MAP, CONF_AUDIO_PACKET_SIZE, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -48,13 +51,18 @@ from .const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + SERV_DOORBELL, SERV_MOTION_SENSOR, + SERV_SPEAKER, ) from .img_util import scale_jpeg_camera_image from .util import pid_is_alive _LOGGER = logging.getLogger(__name__) +DOORBELL_SINGLE_PRESS = 0 +DOORBELL_DOUBLE_PRESS = 1 +DOORBELL_LONG_PRESS = 2 VIDEO_OUTPUT = ( "-map {v_map} -an " @@ -190,18 +198,32 @@ 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(state) + if self.linked_motion_sensor: + state = self.hass.states.get(self.linked_motion_sensor) + if state: + 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(state) + + self._char_doorbell_detected = None + self.linked_doorbell_sensor = self.config.get(CONF_LINKED_DOORBELL_SENSOR) + if self.linked_doorbell_sensor: + state = self.hass.states.get(self.linked_doorbell_sensor) + if state: + serv_doorbell = self.add_preload_service(SERV_DOORBELL) + self.set_primary_service(serv_doorbell) + self._char_doorbell_detected = serv_doorbell.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, value=0, + ) + serv_speaker = self.add_preload_service(SERV_SPEAKER) + serv_speaker.configure_char(CHAR_MUTE, value=0) + + self._async_update_doorbell_state(state) async def run_handler(self): """Handle accessory driver started event. @@ -215,6 +237,13 @@ class Camera(HomeAccessory, PyhapCamera): self._async_update_motion_state_event, ) + if self._char_doorbell_detected: + async_track_state_change_event( + self.hass, + [self.linked_doorbell_sensor], + self._async_update_doorbell_state_event, + ) + await super().run_handler() @callback @@ -240,6 +269,26 @@ class Camera(HomeAccessory, PyhapCamera): detected, ) + @callback + def _async_update_doorbell_state_event(self, event): + """Handle state change event listener callback.""" + self._async_update_doorbell_state(event.data.get("new_state")) + + @callback + def _async_update_doorbell_state(self, new_state): + """Handle link doorbell sensor state change to update HomeKit value.""" + if not new_state: + return + + if new_state.state == STATE_ON: + self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS) + _LOGGER.debug( + "%s: Set linked doorbell %s sensor to %d", + self.entity_id, + self.linked_doorbell_sensor, + DOORBELL_SINGLE_PRESS, + ) + @callback def async_update_state(self, new_state): """Handle state change to update HomeKit value.""" diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 449d2506d04..201a0529f82 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -34,6 +34,7 @@ from .const import ( CONF_FEATURE_LIST, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_LOW_BATTERY_THRESHOLD, @@ -127,6 +128,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( CONF_VIDEO_PACKET_SIZE, default=DEFAULT_VIDEO_PACKET_SIZE ): cv.positive_int, vol.Optional(CONF_LINKED_MOTION_SENSOR): cv.entity_domain(binary_sensor.DOMAIN), + vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain( + binary_sensor.DOMAIN + ), } ) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index dbc28cb1ea8..9e8faa34d38 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -10,12 +10,16 @@ from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( AUDIO_CODEC_COPY, CHAR_MOTION_DETECTED, + CHAR_PROGRAMMABLE_SWITCH_EVENT, CONF_AUDIO_CODEC, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + SERV_DOORBELL, SERV_MOTION_SENSOR, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, @@ -601,3 +605,101 @@ async def test_camera_with_a_missing_linked_motion_sensor(hass, run_driver, even assert acc.category == 17 # Camera assert not acc.get_service(SERV_MOTION_SENSOR) + + +async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): + """Test a camera with a linked doorbell sensor can update.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + doorbell_entity_id = "binary_sensor.doorbell" + + hass.states.async_set( + doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + 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_DOORBELL_SENSOR: doorbell_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_DOORBELL) + assert service + char = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char + + assert char.value == 0 + + hass.states.async_set( + doorbell_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + await hass.async_block_till_done() + assert char.value == 0 + + char.set_value(True) + hass.states.async_set( + doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + await hass.async_block_till_done() + assert char.value == 0 + + # Ensure we do not throw when the linked + # doorbell sensor is removed + hass.states.async_remove(doorbell_entity_id) + await hass.async_block_till_done() + await acc.run_handler() + await hass.async_block_till_done() + assert char.value == 0 + + +async def test_camera_with_a_missing_linked_doorbell_sensor(hass, run_driver, events): + """Test a camera with a configured linked doorbell sensor that is missing.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + doorbell_entity_id = "binary_sensor.doorbell" + 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_DOORBELL_SENSOR: doorbell_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_DOORBELL)