diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py index 863b2833851..e41c3d02e9e 100644 --- a/homeassistant/components/private_ble_device/coordinator.py +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -102,6 +102,16 @@ class PrivateDevicesCoordinator: def _async_irk_resolved_to_mac(self, irk: bytes, mac: str) -> None: if previous_mac := self._irk_to_mac.get(irk): + previous_interval = bluetooth.async_get_learned_advertising_interval( + self.hass, previous_mac + ) or bluetooth.async_get_fallback_availability_interval( + self.hass, previous_mac + ) + if previous_interval: + bluetooth.async_set_fallback_availability_interval( + self.hass, mac, previous_interval + ) + self._mac_to_irk.pop(previous_mac, None) self._mac_to_irk[mac] = irk diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index e2f5efb6699..b332d057ba9 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfLength, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,7 +30,9 @@ from .entity import BasePrivateDeviceEntity class PrivateDeviceSensorEntityDescriptionRequired: """Required domain specific fields for sensor entity.""" - value_fn: Callable[[bluetooth.BluetoothServiceInfoBleak], str | int | float | None] + value_fn: Callable[ + [HomeAssistant, bluetooth.BluetoothServiceInfoBleak], str | int | float | None + ] @dataclass @@ -46,7 +49,7 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda service_info: service_info.advertisement.rssi, + value_fn=lambda _, service_info: service_info.advertisement.rssi, state_class=SensorStateClass.MEASUREMENT, ), PrivateDeviceSensorEntityDescription( @@ -56,7 +59,7 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda service_info: service_info.advertisement.tx_power, + value_fn=lambda _, service_info: service_info.advertisement.tx_power, state_class=SensorStateClass.MEASUREMENT, ), PrivateDeviceSensorEntityDescription( @@ -64,7 +67,7 @@ SENSOR_DESCRIPTIONS = ( translation_key="estimated_distance", icon="mdi:signal-distance-variant", native_unit_of_measurement=UnitOfLength.METERS, - value_fn=lambda service_info: service_info.advertisement + value_fn=lambda _, service_info: service_info.advertisement and service_info.advertisement.tx_power and calculate_distance_meters( service_info.advertisement.tx_power * 10, service_info.advertisement.rssi @@ -73,6 +76,22 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, ), + PrivateDeviceSensorEntityDescription( + key="estimated_broadcast_interval", + translation_key="estimated_broadcast_interval", + icon="mdi:timer-sync-outline", + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda hass, service_info: bluetooth.async_get_learned_advertising_interval( + hass, service_info.address + ) + or bluetooth.async_get_fallback_availability_interval( + hass, service_info.address + ) + or bluetooth.FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + suggested_display_precision=1, + ), ) @@ -124,4 +143,4 @@ class PrivateBLEDeviceSensor(BasePrivateDeviceEntity, SensorEntity): def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" assert self._last_info - return self.entity_description.value_fn(self._last_info) + return self.entity_description.value_fn(self.hass, self._last_info) diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index 279ff38bc9b..9e20a9476ec 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -24,6 +24,9 @@ }, "estimated_distance": { "name": "Estimated distance" + }, + "estimated_broadcast_interval": { + "name": "Estimated broadcast interval" } } } diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index 776ba503983..d8b30738865 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -6,6 +6,9 @@ import time from homeassistant.components.bluetooth.advertisement_tracker import ( ADVERTISING_TIMES_NEEDED, ) +from homeassistant.components.bluetooth.api import ( + async_get_fallback_availability_interval, +) from homeassistant.core import HomeAssistant from . import ( @@ -181,3 +184,23 @@ async def test_old_tracker_leave_home( state = hass.states.get("device_tracker.private_ble_device_000000") assert state assert state.state == "not_home" + + +async def test_mac_rotation( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we receive a broadcast.""" + await async_mock_config_entry(hass) + + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_1) is None + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_2) is None + + for i in range(ADVERTISING_TIMES_NEEDED): + await async_inject_broadcast( + hass, MAC_RPA_VALID_1, mfr_data=bytes(i), broadcast_time=i * 10 + ) + + await async_inject_broadcast(hass, MAC_RPA_VALID_2) + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_2) == 10 diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 820ec2199ad..65f08d5653d 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,9 +1,18 @@ """Tests for sensors.""" +from homeassistant.components.bluetooth import async_set_fallback_availability_interval +from homeassistant.components.bluetooth.advertisement_tracker import ( + ADVERTISING_TIMES_NEEDED, +) from homeassistant.core import HomeAssistant -from . import MAC_RPA_VALID_1, async_inject_broadcast, async_mock_config_entry +from . import ( + MAC_RPA_VALID_1, + MAC_RPA_VALID_2, + async_inject_broadcast, + async_mock_config_entry, +) async def test_sensor_unavailable( @@ -45,3 +54,55 @@ async def test_sensors_come_home( state = hass.states.get("sensor.private_ble_device_000000_signal_strength") assert state assert state.state == "-63" + + +async def test_estimated_broadcast_interval( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we receive a broadcast.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + + # With no fallback and no learned interval, we should use the global default + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "900" + + # Fallback interval trumps const default + + async_set_fallback_availability_interval(hass, MAC_RPA_VALID_1, 90) + await async_inject_broadcast(hass, MAC_RPA_VALID_1.upper()) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "90" + + # Learned broadcast interval takes over from fallback interval + + for i in range(ADVERTISING_TIMES_NEEDED): + await async_inject_broadcast( + hass, MAC_RPA_VALID_1, mfr_data=bytes(i), broadcast_time=i * 10 + ) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "10" + + # MAC address changes, the broadcast interval is kept + + await async_inject_broadcast(hass, MAC_RPA_VALID_2.upper()) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "10"