Handle iBeacons that broadcast multiple different uuids (#79011)

* Handle iBeacons that broadcast multiple different uuids

* fix flip-flopping between uuids

* naming
This commit is contained in:
J. Nick Koston 2022-09-23 14:45:09 -10:00 committed by GitHub
parent fc58d88770
commit 02731efc4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 180 additions and 57 deletions

View File

@ -54,7 +54,7 @@ def make_short_address(address: str) -> str:
@callback @callback
def async_name( def async_name(
service_info: bluetooth.BluetoothServiceInfoBleak, service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
unique_address: bool = False, unique_address: bool = False,
) -> str: ) -> str:
"""Return a name for the device.""" """Return a name for the device."""
@ -62,7 +62,7 @@ def async_name(
service_info.name, service_info.name,
service_info.name.replace("_", ":"), service_info.name.replace("_", ":"),
): ):
base_name = f"{parsed.uuid} {parsed.major}.{parsed.minor}" base_name = f"{ibeacon_advertisement.uuid} {ibeacon_advertisement.major}.{ibeacon_advertisement.minor}"
else: else:
base_name = service_info.name base_name = service_info.name
if unique_address: if unique_address:
@ -77,7 +77,7 @@ def _async_dispatch_update(
hass: HomeAssistant, hass: HomeAssistant,
device_id: str, device_id: str,
service_info: bluetooth.BluetoothServiceInfoBleak, service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
new: bool, new: bool,
unique_address: bool, unique_address: bool,
) -> None: ) -> None:
@ -87,15 +87,15 @@ def _async_dispatch_update(
hass, hass,
SIGNAL_IBEACON_DEVICE_NEW, SIGNAL_IBEACON_DEVICE_NEW,
device_id, device_id,
async_name(service_info, parsed, unique_address), async_name(service_info, ibeacon_advertisement, unique_address),
parsed, ibeacon_advertisement,
) )
return return
async_dispatcher_send( async_dispatcher_send(
hass, hass,
signal_seen(device_id), signal_seen(device_id),
parsed, ibeacon_advertisement,
) )
@ -117,7 +117,9 @@ class IBeaconCoordinator:
) )
# iBeacons with fixed MAC addresses # iBeacons with fixed MAC addresses
self._last_rssi_by_unique_id: dict[str, int] = {} self._last_ibeacon_advertisement_by_unique_id: dict[
str, iBeaconAdvertisement
] = {}
self._group_ids_by_address: dict[str, set[str]] = {} self._group_ids_by_address: dict[str, set[str]] = {}
self._unique_ids_by_address: dict[str, set[str]] = {} self._unique_ids_by_address: dict[str, set[str]] = {}
self._unique_ids_by_group_id: dict[str, set[str]] = {} self._unique_ids_by_group_id: dict[str, set[str]] = {}
@ -162,21 +164,23 @@ class IBeaconCoordinator:
for unique_id in unique_ids: for unique_id in unique_ids:
if device := self._dev_reg.async_get_device({(DOMAIN, unique_id)}): if device := self._dev_reg.async_get_device({(DOMAIN, unique_id)}):
self._dev_reg.async_remove_device(device.id) self._dev_reg.async_remove_device(device.id)
self._last_rssi_by_unique_id.pop(unique_id, None) self._last_ibeacon_advertisement_by_unique_id.pop(unique_id, None)
@callback @callback
def _async_convert_random_mac_tracking( def _async_convert_random_mac_tracking(
self, self,
group_id: str, group_id: str,
service_info: bluetooth.BluetoothServiceInfoBleak, service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Switch to random mac tracking method when a group is using rotating mac addresses.""" """Switch to random mac tracking method when a group is using rotating mac addresses."""
self._group_ids_random_macs.add(group_id) self._group_ids_random_macs.add(group_id)
self._async_purge_untrackable_entities(self._unique_ids_by_group_id[group_id]) self._async_purge_untrackable_entities(self._unique_ids_by_group_id[group_id])
self._unique_ids_by_group_id.pop(group_id) self._unique_ids_by_group_id.pop(group_id)
self._addresses_by_group_id.pop(group_id) self._addresses_by_group_id.pop(group_id)
self._async_update_ibeacon_with_random_mac(group_id, service_info, parsed) self._async_update_ibeacon_with_random_mac(
group_id, service_info, ibeacon_advertisement
)
def _async_track_ibeacon_with_unique_address( def _async_track_ibeacon_with_unique_address(
self, address: str, group_id: str, unique_id: str self, address: str, group_id: str, unique_id: str
@ -197,49 +201,55 @@ class IBeaconCoordinator:
"""Update from a bluetooth callback.""" """Update from a bluetooth callback."""
if service_info.address in self._ignore_addresses: if service_info.address in self._ignore_addresses:
return return
if not (parsed := parse(service_info)): if not (ibeacon_advertisement := parse(service_info)):
return return
group_id = f"{parsed.uuid}_{parsed.major}_{parsed.minor}" group_id = f"{ibeacon_advertisement.uuid}_{ibeacon_advertisement.major}_{ibeacon_advertisement.minor}"
if group_id in self._group_ids_random_macs: if group_id in self._group_ids_random_macs:
self._async_update_ibeacon_with_random_mac(group_id, service_info, parsed) self._async_update_ibeacon_with_random_mac(
group_id, service_info, ibeacon_advertisement
)
return return
self._async_update_ibeacon_with_unique_address(group_id, service_info, parsed) self._async_update_ibeacon_with_unique_address(
group_id, service_info, ibeacon_advertisement
)
@callback @callback
def _async_update_ibeacon_with_random_mac( def _async_update_ibeacon_with_random_mac(
self, self,
group_id: str, group_id: str,
service_info: bluetooth.BluetoothServiceInfoBleak, service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Update iBeacons with random mac addresses.""" """Update iBeacons with random mac addresses."""
new = group_id not in self._last_seen_by_group_id new = group_id not in self._last_seen_by_group_id
self._last_seen_by_group_id[group_id] = service_info self._last_seen_by_group_id[group_id] = service_info
self._unavailable_group_ids.discard(group_id) self._unavailable_group_ids.discard(group_id)
_async_dispatch_update(self.hass, group_id, service_info, parsed, new, False) _async_dispatch_update(
self.hass, group_id, service_info, ibeacon_advertisement, new, False
)
@callback @callback
def _async_update_ibeacon_with_unique_address( def _async_update_ibeacon_with_unique_address(
self, self,
group_id: str, group_id: str,
service_info: bluetooth.BluetoothServiceInfoBleak, service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
# Handle iBeacon with a fixed mac address # Handle iBeacon with a fixed mac address
# and or detect if the iBeacon is using a rotating mac address # and or detect if the iBeacon is using a rotating mac address
# and switch to random mac tracking method # and switch to random mac tracking method
address = service_info.address address = service_info.address
unique_id = f"{group_id}_{address}" unique_id = f"{group_id}_{address}"
new = unique_id not in self._last_rssi_by_unique_id new = unique_id not in self._last_ibeacon_advertisement_by_unique_id
# Reject creating new trackers if the name is not set # Reject creating new trackers if the name is not set
if new and ( if new and (
service_info.device.name is None service_info.device.name is None
or service_info.device.name.replace("-", ":") == service_info.device.address or service_info.device.name.replace("-", ":") == service_info.device.address
): ):
return return
self._last_rssi_by_unique_id[unique_id] = service_info.rssi self._last_ibeacon_advertisement_by_unique_id[unique_id] = ibeacon_advertisement
self._async_track_ibeacon_with_unique_address(address, group_id, unique_id) self._async_track_ibeacon_with_unique_address(address, group_id, unique_id)
if address not in self._unavailable_trackers: if address not in self._unavailable_trackers:
self._unavailable_trackers[address] = bluetooth.async_track_unavailable( self._unavailable_trackers[address] = bluetooth.async_track_unavailable(
@ -259,10 +269,14 @@ class IBeaconCoordinator:
# group_id we remove all the trackers for that group_id # group_id we remove all the trackers for that group_id
# as it means the addresses are being rotated. # as it means the addresses are being rotated.
if len(self._addresses_by_group_id[group_id]) >= MAX_IDS: if len(self._addresses_by_group_id[group_id]) >= MAX_IDS:
self._async_convert_random_mac_tracking(group_id, service_info, parsed) self._async_convert_random_mac_tracking(
group_id, service_info, ibeacon_advertisement
)
return return
_async_dispatch_update(self.hass, unique_id, service_info, parsed, new, True) _async_dispatch_update(
self.hass, unique_id, service_info, ibeacon_advertisement, new, True
)
@callback @callback
def _async_stop(self) -> None: def _async_stop(self) -> None:
@ -294,21 +308,21 @@ class IBeaconCoordinator:
here and send them over the dispatcher periodically to here and send them over the dispatcher periodically to
ensure the distance calculation is update. ensure the distance calculation is update.
""" """
for unique_id, rssi in self._last_rssi_by_unique_id.items(): for (
unique_id,
ibeacon_advertisement,
) in self._last_ibeacon_advertisement_by_unique_id.items():
address = unique_id.split("_")[-1] address = unique_id.split("_")[-1]
if ( if (
( service_info := bluetooth.async_last_service_info(
service_info := bluetooth.async_last_service_info( self.hass, address, connectable=False
self.hass, address, connectable=False
)
) )
and service_info.rssi != rssi ) and service_info.rssi != ibeacon_advertisement.rssi:
and (parsed := parse(service_info)) ibeacon_advertisement.update_rssi(service_info.rssi)
):
async_dispatcher_send( async_dispatcher_send(
self.hass, self.hass,
signal_seen(unique_id), signal_seen(unique_id),
parsed, ibeacon_advertisement,
) )
@callback @callback

View File

@ -26,7 +26,7 @@ async def async_setup_entry(
def _async_device_new( def _async_device_new(
unique_id: str, unique_id: str,
identifier: str, identifier: str,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Signal a new device.""" """Signal a new device."""
async_add_entities( async_add_entities(
@ -35,7 +35,7 @@ async def async_setup_entry(
coordinator, coordinator,
identifier, identifier,
unique_id, unique_id,
parsed, ibeacon_advertisement,
) )
] ]
) )
@ -53,10 +53,12 @@ class IBeaconTrackerEntity(IBeaconEntity, BaseTrackerEntity):
coordinator: IBeaconCoordinator, coordinator: IBeaconCoordinator,
identifier: str, identifier: str,
device_unique_id: str, device_unique_id: str,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Initialize an iBeacon tracker entity.""" """Initialize an iBeacon tracker entity."""
super().__init__(coordinator, identifier, device_unique_id, parsed) super().__init__(
coordinator, identifier, device_unique_id, ibeacon_advertisement
)
self._attr_unique_id = device_unique_id self._attr_unique_id = device_unique_id
self._active = True self._active = True
@ -78,11 +80,11 @@ class IBeaconTrackerEntity(IBeaconEntity, BaseTrackerEntity):
@callback @callback
def _async_seen( def _async_seen(
self, self,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Update state.""" """Update state."""
self._active = True self._active = True
self._parsed = parsed self._ibeacon_advertisement = ibeacon_advertisement
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback

View File

@ -24,12 +24,12 @@ class IBeaconEntity(Entity):
coordinator: IBeaconCoordinator, coordinator: IBeaconCoordinator,
identifier: str, identifier: str,
device_unique_id: str, device_unique_id: str,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Initialize an iBeacon sensor entity.""" """Initialize an iBeacon sensor entity."""
self._device_unique_id = device_unique_id self._device_unique_id = device_unique_id
self._coordinator = coordinator self._coordinator = coordinator
self._parsed = parsed self._ibeacon_advertisement = ibeacon_advertisement
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
name=identifier, name=identifier,
identifiers={(DOMAIN, device_unique_id)}, identifiers={(DOMAIN, device_unique_id)},
@ -40,19 +40,19 @@ class IBeaconEntity(Entity):
self, self,
) -> dict[str, str | int]: ) -> dict[str, str | int]:
"""Return the device state attributes.""" """Return the device state attributes."""
parsed = self._parsed ibeacon_advertisement = self._ibeacon_advertisement
return { return {
ATTR_UUID: str(parsed.uuid), ATTR_UUID: str(ibeacon_advertisement.uuid),
ATTR_MAJOR: parsed.major, ATTR_MAJOR: ibeacon_advertisement.major,
ATTR_MINOR: parsed.minor, ATTR_MINOR: ibeacon_advertisement.minor,
ATTR_SOURCE: parsed.source, ATTR_SOURCE: ibeacon_advertisement.source,
} }
@abstractmethod @abstractmethod
@callback @callback
def _async_seen( def _async_seen(
self, self,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Update state.""" """Update state."""

View File

@ -4,7 +4,7 @@
"documentation": "https://www.home-assistant.io/integrations/ibeacon", "documentation": "https://www.home-assistant.io/integrations/ibeacon",
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [2, 21] }], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [2, 21] }],
"requirements": ["ibeacon_ble==0.6.4"], "requirements": ["ibeacon_ble==0.7.0"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["bleak"], "loggers": ["bleak"],

View File

@ -42,7 +42,7 @@ SENSOR_DESCRIPTIONS = (
device_class=SensorDeviceClass.SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda parsed: parsed.rssi, value_fn=lambda ibeacon_advertisement: ibeacon_advertisement.rssi,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
IBeaconSensorEntityDescription( IBeaconSensorEntityDescription(
@ -51,7 +51,7 @@ SENSOR_DESCRIPTIONS = (
device_class=SensorDeviceClass.SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda parsed: parsed.power, value_fn=lambda ibeacon_advertisement: ibeacon_advertisement.power,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
IBeaconSensorEntityDescription( IBeaconSensorEntityDescription(
@ -59,7 +59,7 @@ SENSOR_DESCRIPTIONS = (
name="Estimated Distance", name="Estimated Distance",
icon="mdi:signal-distance-variant", icon="mdi:signal-distance-variant",
native_unit_of_measurement=LENGTH_METERS, native_unit_of_measurement=LENGTH_METERS,
value_fn=lambda parsed: parsed.distance, value_fn=lambda ibeacon_advertisement: ibeacon_advertisement.distance,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
) )
@ -75,7 +75,7 @@ async def async_setup_entry(
def _async_device_new( def _async_device_new(
unique_id: str, unique_id: str,
identifier: str, identifier: str,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Signal a new device.""" """Signal a new device."""
async_add_entities( async_add_entities(
@ -84,7 +84,7 @@ async def async_setup_entry(
description, description,
identifier, identifier,
unique_id, unique_id,
parsed, ibeacon_advertisement,
) )
for description in SENSOR_DESCRIPTIONS for description in SENSOR_DESCRIPTIONS
) )
@ -105,21 +105,23 @@ class IBeaconSensorEntity(IBeaconEntity, SensorEntity):
description: IBeaconSensorEntityDescription, description: IBeaconSensorEntityDescription,
identifier: str, identifier: str,
device_unique_id: str, device_unique_id: str,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Initialize an iBeacon sensor entity.""" """Initialize an iBeacon sensor entity."""
super().__init__(coordinator, identifier, device_unique_id, parsed) super().__init__(
coordinator, identifier, device_unique_id, ibeacon_advertisement
)
self._attr_unique_id = f"{device_unique_id}_{description.key}" self._attr_unique_id = f"{device_unique_id}_{description.key}"
self.entity_description = description self.entity_description = description
@callback @callback
def _async_seen( def _async_seen(
self, self,
parsed: iBeaconAdvertisement, ibeacon_advertisement: iBeaconAdvertisement,
) -> None: ) -> None:
"""Update state.""" """Update state."""
self._attr_available = True self._attr_available = True
self._parsed = parsed self._ibeacon_advertisement = ibeacon_advertisement
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
@ -131,4 +133,4 @@ class IBeaconSensorEntity(IBeaconEntity, SensorEntity):
@property @property
def native_value(self) -> int | None: def native_value(self) -> int | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.value_fn(self._parsed) return self.entity_description.value_fn(self._ibeacon_advertisement)

View File

@ -898,7 +898,7 @@ iammeter==0.1.7
iaqualink==0.4.1 iaqualink==0.4.1
# homeassistant.components.ibeacon # homeassistant.components.ibeacon
ibeacon_ble==0.6.4 ibeacon_ble==0.7.0
# homeassistant.components.watson_tts # homeassistant.components.watson_tts
ibm-watson==5.2.2 ibm-watson==5.2.2

View File

@ -663,7 +663,7 @@ hyperion-py==0.7.5
iaqualink==0.4.1 iaqualink==0.4.1
# homeassistant.components.ibeacon # homeassistant.components.ibeacon
ibeacon_ble==0.6.4 ibeacon_ble==0.7.0
# homeassistant.components.ping # homeassistant.components.ping
icmplib==3.0 icmplib==3.0

View File

@ -58,3 +58,46 @@ BEACON_RANDOM_ADDRESS_SERVICE_INFO = BluetoothServiceInfo(
service_uuids=[], service_uuids=[],
source="local", source="local",
) )
FEASY_BEACON_BLE_DEVICE = BLEDevice(
address="AA:BB:CC:DD:EE:FF",
name="FSC-BP108",
)
FEASY_BEACON_SERVICE_INFO_1 = BluetoothServiceInfo(
name="FSC-BP108",
address="AA:BB:CC:DD:EE:FF",
rssi=-63,
manufacturer_data={
76: b"\x02\x15\xfd\xa5\x06\x93\xa4\xe2O\xb1\xaf\xcf\xc6\xeb\x07dx%'Qe\xc1\xfd"
},
service_data={
"0000feaa-0000-1000-8000-00805f9b34fb": b' \x00\x0c\x86\x80\x00\x00\x00\x93f\x0b\x7f\x93"',
"0000fff0-0000-1000-8000-00805f9b34fb": b"'\x02\x17\x92\xdc\r0\x0e \xbad",
},
service_uuids=[
"0000feaa-0000-1000-8000-00805f9b34fb",
"0000fef5-0000-1000-8000-00805f9b34fb",
],
source="local",
)
FEASY_BEACON_SERVICE_INFO_2 = BluetoothServiceInfo(
name="FSC-BP108",
address="AA:BB:CC:DD:EE:FF",
rssi=-63,
manufacturer_data={
76: b"\x02\x15\xd5F\xdf\x97GWG\xef\xbe\t>-\xcb\xdd\x0cw\xed\xd1;\xd2\xb5"
},
service_data={
"0000feaa-0000-1000-8000-00805f9b34fb": b' \x00\x0c\x86\x80\x00\x00\x00\x93f\x0b\x7f\x93"',
"0000fff0-0000-1000-8000-00805f9b34fb": b"'\x02\x17\x92\xdc\r0\x0e \xbad",
},
service_uuids=[
"0000feaa-0000-1000-8000-00805f9b34fb",
"0000fef5-0000-1000-8000-00805f9b34fb",
],
source="local",
)

View File

@ -20,6 +20,9 @@ from . import (
BLUECHARM_BEACON_SERVICE_INFO, BLUECHARM_BEACON_SERVICE_INFO,
BLUECHARM_BEACON_SERVICE_INFO_2, BLUECHARM_BEACON_SERVICE_INFO_2,
BLUECHARM_BLE_DEVICE, BLUECHARM_BLE_DEVICE,
FEASY_BEACON_BLE_DEVICE,
FEASY_BEACON_SERVICE_INFO_1,
FEASY_BEACON_SERVICE_INFO_2,
NO_NAME_BEACON_SERVICE_INFO, NO_NAME_BEACON_SERVICE_INFO,
) )
@ -182,3 +185,62 @@ async def test_can_unload_and_reload(hass):
assert ( assert (
hass.states.get("sensor.bluecharm_177999_8105_estimated_distance").state == "2" hass.states.get("sensor.bluecharm_177999_8105_estimated_distance").state == "2"
) )
async def test_multiple_uuids_same_beacon(hass):
"""Test a beacon that broadcasts multiple uuids."""
entry = MockConfigEntry(
domain=DOMAIN,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
with patch_all_discovered_devices([FEASY_BEACON_BLE_DEVICE]):
inject_bluetooth_service_info(hass, FEASY_BEACON_SERVICE_INFO_1)
await hass.async_block_till_done()
distance_sensor = hass.states.get("sensor.fsc_bp108_eeff_estimated_distance")
distance_attributes = distance_sensor.attributes
assert distance_sensor.state == "400"
assert (
distance_attributes[ATTR_FRIENDLY_NAME] == "FSC-BP108 EEFF Estimated Distance"
)
assert distance_attributes[ATTR_UNIT_OF_MEASUREMENT] == "m"
assert distance_attributes[ATTR_STATE_CLASS] == "measurement"
with patch_all_discovered_devices([FEASY_BEACON_BLE_DEVICE]):
inject_bluetooth_service_info(hass, FEASY_BEACON_SERVICE_INFO_2)
await hass.async_block_till_done()
distance_sensor = hass.states.get("sensor.fsc_bp108_eeff_estimated_distance_2")
distance_attributes = distance_sensor.attributes
assert distance_sensor.state == "0"
assert (
distance_attributes[ATTR_FRIENDLY_NAME] == "FSC-BP108 EEFF Estimated Distance"
)
assert distance_attributes[ATTR_UNIT_OF_MEASUREMENT] == "m"
assert distance_attributes[ATTR_STATE_CLASS] == "measurement"
with patch_all_discovered_devices([FEASY_BEACON_BLE_DEVICE]):
inject_bluetooth_service_info(hass, FEASY_BEACON_SERVICE_INFO_1)
await hass.async_block_till_done()
distance_sensor = hass.states.get("sensor.fsc_bp108_eeff_estimated_distance")
distance_attributes = distance_sensor.attributes
assert distance_sensor.state == "400"
assert (
distance_attributes[ATTR_FRIENDLY_NAME] == "FSC-BP108 EEFF Estimated Distance"
)
assert distance_attributes[ATTR_UNIT_OF_MEASUREMENT] == "m"
assert distance_attributes[ATTR_STATE_CLASS] == "measurement"
distance_sensor = hass.states.get("sensor.fsc_bp108_eeff_estimated_distance_2")
distance_attributes = distance_sensor.attributes
assert distance_sensor.state == "0"
assert (
distance_attributes[ATTR_FRIENDLY_NAME] == "FSC-BP108 EEFF Estimated Distance"
)
assert distance_attributes[ATTR_UNIT_OF_MEASUREMENT] == "m"
assert distance_attributes[ATTR_STATE_CLASS] == "measurement"