diff --git a/homeassistant/components/private_ble_device/__init__.py b/homeassistant/components/private_ble_device/__init__.py index c4666ccc02f..dcb6555bbc9 100644 --- a/homeassistant/components/private_ble_device/__init__.py +++ b/homeassistant/components/private_ble_device/__init__.py @@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/private_ble_device/entity.py b/homeassistant/components/private_ble_device/entity.py index ae632213506..978313e9671 100644 --- a/homeassistant/components/private_ble_device/entity.py +++ b/homeassistant/components/private_ble_device/entity.py @@ -24,7 +24,10 @@ class BasePrivateDeviceEntity(Entity): """Set up a new BleScanner entity.""" irk = config_entry.data["irk"] - self._attr_unique_id = irk + if self.translation_key: + self._attr_unique_id = f"{irk}_{self.translation_key}" + else: + self._attr_unique_id = irk self._attr_device_info = DeviceInfo( name=f"Private BLE Device {irk[:6]}", diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py new file mode 100644 index 00000000000..c2ec4ca39ce --- /dev/null +++ b/homeassistant/components/private_ble_device/sensor.py @@ -0,0 +1,126 @@ +"""Support for iBeacon device sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bluetooth_data_tools import calculate_distance_meters + +from homeassistant.components import bluetooth +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfLength, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import BasePrivateDeviceEntity + + +@dataclass +class PrivateDeviceSensorEntityDescriptionRequired: + """Required domain specific fields for sensor entity.""" + + value_fn: Callable[[bluetooth.BluetoothServiceInfoBleak], str | int | float | None] + + +@dataclass +class PrivateDeviceSensorEntityDescription( + SensorEntityDescription, PrivateDeviceSensorEntityDescriptionRequired +): + """Describes sensor entity.""" + + +SENSOR_DESCRIPTIONS = ( + PrivateDeviceSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + 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, + state_class=SensorStateClass.MEASUREMENT, + ), + PrivateDeviceSensorEntityDescription( + key="power", + translation_key="power", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + 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, + state_class=SensorStateClass.MEASUREMENT, + ), + PrivateDeviceSensorEntityDescription( + key="estimated_distance", + translation_key="estimated_distance", + icon="mdi:signal-distance-variant", + native_unit_of_measurement=UnitOfLength.METERS, + 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 + ), + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensors for Private BLE component.""" + async_add_entities( + PrivateBLEDeviceSensor(entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PrivateBLEDeviceSensor(BasePrivateDeviceEntity, SensorEntity): + """A sensor entity.""" + + entity_description: PrivateDeviceSensorEntityDescription + + def __init__( + self, + config_entry: ConfigEntry, + entity_description: PrivateDeviceSensorEntityDescription, + ) -> None: + """Initialize an sensor entity.""" + self.entity_description = entity_description + self._attr_available = False + super().__init__(config_entry) + + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update state.""" + self._attr_available = True + self._last_info = service_info + self.async_write_ha_state() + + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Update state.""" + self._attr_available = False + self.async_write_ha_state() + + @property + 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) diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index c62ea5c4d50..279ff38bc9b 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -16,5 +16,15 @@ "abort": { "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." } + }, + "entity": { + "sensor": { + "power": { + "name": "Power" + }, + "estimated_distance": { + "name": "Estimated distance" + } + } } } diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py new file mode 100644 index 00000000000..820ec2199ad --- /dev/null +++ b/tests/components/private_ble_device/test_sensor.py @@ -0,0 +1,47 @@ +"""Tests for sensors.""" + + +from homeassistant.core import HomeAssistant + +from . import MAC_RPA_VALID_1, async_inject_broadcast, async_mock_config_entry + + +async def test_sensor_unavailable( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors are unavailable.""" + await async_mock_config_entry(hass) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "unavailable" + + +async def test_sensors_already_home( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we start at home.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "-63" + + +async def test_sensors_come_home( + 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) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "-63"