diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d288fe66e2b..6e9d0640e8c 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -8,6 +8,7 @@ import dataclasses from uiprotect.data import ( NVR, Camera, + Event, ModelType, MountType, ProtectAdoptableDeviceModel, @@ -644,6 +645,31 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): self._attr_is_on = False self._attr_extra_state_attributes = {} + @callback + def _find_active_event_with_object_type( + self, device: ProtectDeviceType + ) -> Event | None: + """Find an active event containing this sensor's object type. + + Fallback for issue #152133: last_smart_detect_event_ids may not update + immediately when a new detection type is added to an ongoing event. + """ + obj_type = self.entity_description.ufp_obj_type + if obj_type is None or not isinstance(device, Camera): + return None + + # Check known active event IDs from camera first (fast path) + for event_id in device.last_smart_detect_event_ids.values(): + if ( + event_id + and (event := self.data.api.bootstrap.events.get(event_id)) + and event.end is None + and obj_type in event.smart_detect_types + ): + return event + + return None + @callback def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: description = self.entity_description @@ -651,9 +677,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): prev_event = self._event prev_event_end = self._event_end super()._async_update_device_from_protect(device) - if event := description.get_event_obj(device): + + event = description.get_event_obj(device) + if event is None: + # Fallback for #152133: check active events directly + event = self._find_active_event_with_object_type(device) + + if event: self._event = event - self._event_end = event.end if event else None + self._event_end = event.end if not ( event diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 0c4d6e00066..4f7e326aeeb 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -721,3 +721,179 @@ async def test_binary_sensor_person_detected( ufp.ws_msg(mock_msg) await hass.async_block_till_done() assert len(state_changes) == 2 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_simultaneous_person_and_vehicle_detection( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test that when an event is updated with additional detection types, both trigger. + + This is a regression test for https://github.com/home-assistant/core/issues/152133 + where an event starting with vehicle detection gets updated to also include person + detection (e.g., someone getting out of a car). Both sensors should be ON + simultaneously, not queued. + """ + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 15) + + doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON) + doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.VEHICLE) + + # Get entity IDs for both person and vehicle detection + _, person_entity_id = await ids_from_device_description( + hass, + Platform.BINARY_SENSOR, + doorbell, + EVENT_SENSORS[3], # person detected + ) + _, vehicle_entity_id = await ids_from_device_description( + hass, + Platform.BINARY_SENSOR, + doorbell, + EVENT_SENSORS[4], # vehicle detected + ) + + # Step 1: Initial event with only VEHICLE detection (car arriving) + event = Event( + model=ModelType.EVENT, + id="combined_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=5), + end=None, # Event is ongoing + score=90, + smart_detect_types=[SmartDetectObjectType.VEHICLE], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.model_copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = event.id + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + # Vehicle sensor should be ON + vehicle_state = hass.states.get(vehicle_entity_id) + assert vehicle_state + assert vehicle_state.state == STATE_ON, "Vehicle detection should be ON" + + # Person sensor should still be OFF (no person detected yet) + person_state = hass.states.get(person_entity_id) + assert person_state + assert person_state.state == STATE_OFF, "Person detection should be OFF initially" + + # Step 2: Same event gets updated to include PERSON detection + # (someone gets out of the car - Protect adds PERSON to the same event) + # + # BUG SCENARIO: UniFi Protect updates the event to include PERSON in + # smart_detect_types, BUT does NOT update last_smart_detect_event_ids[PERSON] + # until the event ends. This is the core issue reported in #152133. + updated_event = Event( + model=ModelType.EVENT, + id="combined_event_id", # Same event ID! + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=5), + end=None, # Event still ongoing + score=90, + smart_detect_types=[ + SmartDetectObjectType.VEHICLE, + SmartDetectObjectType.PERSON, + ], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + # IMPORTANT: The camera's last_smart_detect_event_ids is NOT updated for PERSON! + # This simulates the real bug where UniFi Protect doesn't immediately update + # the camera's last_smart_detect_event_ids when a new detection type is added + # to an ongoing event. + new_camera = doorbell.model_copy() + new_camera.is_smart_detected = True + # Only VEHICLE has the event ID - PERSON does not (simulating the bug) + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = ( + updated_event.id + ) + # NOTE: We're NOT setting last_smart_detect_event_ids[PERSON] to simulate the bug! + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {updated_event.id: updated_event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = updated_event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + # CRITICAL: Both sensors should now be ON simultaneously + vehicle_state = hass.states.get(vehicle_entity_id) + assert vehicle_state + assert vehicle_state.state == STATE_ON, ( + "Vehicle detection should still be ON after event update" + ) + + person_state = hass.states.get(person_entity_id) + assert person_state + assert person_state.state == STATE_ON, ( + "Person detection should be ON immediately when added to event, " + "not waiting for vehicle detection to end" + ) + + # Verify both have correct attributes + assert vehicle_state.attributes[ATTR_EVENT_SCORE] == 90 + assert person_state.attributes[ATTR_EVENT_SCORE] == 90 + + # Step 3: Event ends - both sensors should turn OFF + ended_event = Event( + model=ModelType.EVENT, + id="combined_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=5), + end=fixed_now, # Event ended now + score=90, + smart_detect_types=[ + SmartDetectObjectType.VEHICLE, + SmartDetectObjectType.PERSON, + ], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + ufp.api.bootstrap.events = {ended_event.id: ended_event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = ended_event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + # Both should be OFF now + vehicle_state = hass.states.get(vehicle_entity_id) + assert vehicle_state + assert vehicle_state.state == STATE_OFF, ( + "Vehicle detection should be OFF after event ends" + ) + + person_state = hass.states.get(person_entity_id) + assert person_state + assert person_state.state == STATE_OFF, ( + "Person detection should be OFF after event ends" + )