Add iBeacon Tracker integration (#78671)

This commit is contained in:
J. Nick Koston 2022-09-19 15:43:41 -10:00 committed by GitHub
parent 12856dea05
commit bb78d52f34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1444 additions and 0 deletions

View file

@ -145,6 +145,7 @@ homeassistant.components.homewizard.*
homeassistant.components.http.*
homeassistant.components.huawei_lte.*
homeassistant.components.hyperion.*
homeassistant.components.ibeacon.*
homeassistant.components.image_processing.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*

View file

@ -508,6 +508,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/iammeter/ @lewei50
/homeassistant/components/iaqualink/ @flz
/tests/components/iaqualink/ @flz
/homeassistant/components/ibeacon/ @bdraco
/tests/components/ibeacon/ @bdraco
/homeassistant/components/icloud/ @Quentame @nzapponi
/tests/components/icloud/ @Quentame @nzapponi
/homeassistant/components/ign_sismologia/ @exxamalte

View file

@ -0,0 +1,24 @@
"""The iBeacon tracker integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import async_get
from .const import DOMAIN, PLATFORMS
from .coordinator import IBeaconCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bluetooth LE Tracker from a config entry."""
coordinator = hass.data[DOMAIN] = IBeaconCoordinator(hass, entry, async_get(hass))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
coordinator.async_start()
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data.pop(DOMAIN)
return unload_ok

View file

@ -0,0 +1,74 @@
"""Config flow for iBeacon Tracker integration."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import bluetooth
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
)
from .const import CONF_MIN_RSSI, DEFAULT_MIN_RSSI, DOMAIN
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for iBeacon Tracker."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if not bluetooth.async_scanner_count(self.hass, connectable=False):
return self.async_abort(reason="bluetooth_not_available")
if user_input is not None:
return self.async_create_entry(title="iBeacon Tracker", data={})
return self.async_show_form(step_id="user")
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow for iBeacons."""
def __init__(self, entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.entry = entry
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
data_schema = vol.Schema(
{
vol.Required(
CONF_MIN_RSSI,
default=self.entry.options.get(CONF_MIN_RSSI) or DEFAULT_MIN_RSSI,
): NumberSelector(
NumberSelectorConfig(
min=-120, max=-30, step=1, mode=NumberSelectorMode.SLIDER
)
),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)

View file

@ -0,0 +1,33 @@
"""Constants for the iBeacon Tracker integration."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "ibeacon"
PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR]
SIGNAL_IBEACON_DEVICE_NEW = "ibeacon_tracker_new_device"
SIGNAL_IBEACON_DEVICE_UNAVAILABLE = "ibeacon_tracker_unavailable_device"
SIGNAL_IBEACON_DEVICE_SEEN = "ibeacon_seen_device"
ATTR_UUID = "uuid"
ATTR_MAJOR = "major"
ATTR_MINOR = "minor"
ATTR_SOURCE = "source"
UNAVAILABLE_TIMEOUT = 180 # Number of seconds we wait for a beacon to be seen before marking it unavailable
# How often to update RSSI if it has changed
# and look for unavailable groups that use a random MAC address
UPDATE_INTERVAL = timedelta(seconds=60)
# If a device broadcasts this many unique ids from the same address
# we will add it to the ignore list since its garbage data.
MAX_IDS = 10
CONF_IGNORE_ADDRESSES = "ignore_addresses"
CONF_MIN_RSSI = "min_rssi"
DEFAULT_MIN_RSSI = -85

View file

@ -0,0 +1,378 @@
"""Tracking for iBeacon devices."""
from __future__ import annotations
from datetime import datetime
import time
from ibeacon_ble import (
APPLE_MFR_ID,
IBEACON_FIRST_BYTE,
IBEACON_SECOND_BYTE,
iBeaconAdvertisement,
is_ibeacon_service_info,
parse,
)
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from .const import (
CONF_IGNORE_ADDRESSES,
CONF_MIN_RSSI,
DEFAULT_MIN_RSSI,
DOMAIN,
MAX_IDS,
SIGNAL_IBEACON_DEVICE_NEW,
SIGNAL_IBEACON_DEVICE_SEEN,
SIGNAL_IBEACON_DEVICE_UNAVAILABLE,
UNAVAILABLE_TIMEOUT,
UPDATE_INTERVAL,
)
MONOTONIC_TIME = time.monotonic
def signal_unavailable(unique_id: str) -> str:
"""Signal for the unique_id going unavailable."""
return f"{SIGNAL_IBEACON_DEVICE_UNAVAILABLE}_{unique_id}"
def signal_seen(unique_id: str) -> str:
"""Signal for the unique_id being seen."""
return f"{SIGNAL_IBEACON_DEVICE_SEEN}_{unique_id}"
def make_short_address(address: str) -> str:
"""Convert a Bluetooth address to a short address."""
results = address.replace("-", ":").split(":")
return f"{results[-2].upper()}{results[-1].upper()}"[-4:]
@callback
def async_name(
service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement,
unique_address: bool = False,
) -> str:
"""Return a name for the device."""
if service_info.address in (
service_info.name,
service_info.name.replace("_", ":"),
):
base_name = f"{parsed.uuid} {parsed.major}.{parsed.minor}"
else:
base_name = service_info.name
if unique_address:
short_address = make_short_address(service_info.address)
if not base_name.endswith(short_address):
return f"{base_name} {short_address}"
return base_name
@callback
def _async_dispatch_update(
hass: HomeAssistant,
device_id: str,
service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement,
new: bool,
unique_address: bool,
) -> None:
"""Dispatch an update."""
if new:
async_dispatcher_send(
hass,
SIGNAL_IBEACON_DEVICE_NEW,
device_id,
async_name(service_info, parsed, unique_address),
parsed,
)
return
async_dispatcher_send(
hass,
signal_seen(device_id),
parsed,
)
class IBeaconCoordinator:
"""Set up the iBeacon Coordinator."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, registry: DeviceRegistry
) -> None:
"""Initialize the Coordinator."""
self.hass = hass
self._entry = entry
self._min_rssi = entry.options.get(CONF_MIN_RSSI) or DEFAULT_MIN_RSSI
self._dev_reg = registry
# iBeacon devices that do not follow the spec
# and broadcast custom data in the major and minor fields
self._ignore_addresses: set[str] = set(
entry.data.get(CONF_IGNORE_ADDRESSES, [])
)
# iBeacons with fixed MAC addresses
self._last_rssi_by_unique_id: dict[str, int] = {}
self._group_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._addresses_by_group_id: dict[str, set[str]] = {}
self._unavailable_trackers: dict[str, CALLBACK_TYPE] = {}
# iBeacon with random MAC addresses
self._group_ids_random_macs: set[str] = set()
self._last_seen_by_group_id: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
self._unavailable_group_ids: set[str] = set()
@callback
def _async_handle_unavailable(
self, service_info: bluetooth.BluetoothServiceInfoBleak
) -> None:
"""Handle unavailable devices."""
address = service_info.address
self._async_cancel_unavailable_tracker(address)
for unique_id in self._unique_ids_by_address[address]:
async_dispatcher_send(self.hass, signal_unavailable(unique_id))
@callback
def _async_cancel_unavailable_tracker(self, address: str) -> None:
"""Cancel unavailable tracking for an address."""
self._unavailable_trackers.pop(address)()
@callback
def _async_ignore_address(self, address: str) -> None:
"""Ignore an address that does not follow the spec and any entities created by it."""
self._ignore_addresses.add(address)
self._async_cancel_unavailable_tracker(address)
self.hass.config_entries.async_update_entry(
self._entry,
data=self._entry.data
| {CONF_IGNORE_ADDRESSES: sorted(self._ignore_addresses)},
)
self._async_purge_untrackable_entities(self._unique_ids_by_address[address])
self._group_ids_by_address.pop(address)
self._unique_ids_by_address.pop(address)
@callback
def _async_purge_untrackable_entities(self, unique_ids: set[str]) -> None:
"""Remove entities that are no longer trackable."""
for unique_id in unique_ids:
if device := self._dev_reg.async_get_device({(DOMAIN, unique_id)}):
self._dev_reg.async_remove_device(device.id)
self._last_rssi_by_unique_id.pop(unique_id, None)
@callback
def _async_convert_random_mac_tracking(
self,
group_id: str,
service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement,
) -> None:
"""Switch to random mac tracking method when a group is using rotating mac addresses."""
self._group_ids_random_macs.add(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._addresses_by_group_id.pop(group_id)
self._async_update_ibeacon_with_random_mac(group_id, service_info, parsed)
def _async_track_ibeacon_with_unique_address(
self, address: str, group_id: str, unique_id: str
) -> None:
"""Track an iBeacon with a unique address."""
self._unique_ids_by_address.setdefault(address, set()).add(unique_id)
self._group_ids_by_address.setdefault(address, set()).add(group_id)
self._unique_ids_by_group_id.setdefault(group_id, set()).add(unique_id)
self._addresses_by_group_id.setdefault(group_id, set()).add(address)
@callback
def _async_update_ibeacon(
self,
service_info: bluetooth.BluetoothServiceInfoBleak,
change: bluetooth.BluetoothChange,
) -> None:
"""Update from a bluetooth callback."""
if service_info.address in self._ignore_addresses:
return
if service_info.rssi < self._min_rssi:
return
if not (parsed := parse(service_info)):
return
group_id = f"{parsed.uuid}_{parsed.major}_{parsed.minor}"
if group_id in self._group_ids_random_macs:
self._async_update_ibeacon_with_random_mac(group_id, service_info, parsed)
return
self._async_update_ibeacon_with_unique_address(group_id, service_info, parsed)
@callback
def _async_update_ibeacon_with_random_mac(
self,
group_id: str,
service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement,
) -> None:
"""Update iBeacons with random mac addresses."""
new = group_id not in self._last_seen_by_group_id
self._last_seen_by_group_id[group_id] = service_info
self._unavailable_group_ids.discard(group_id)
_async_dispatch_update(self.hass, group_id, service_info, parsed, new, False)
@callback
def _async_update_ibeacon_with_unique_address(
self,
group_id: str,
service_info: bluetooth.BluetoothServiceInfoBleak,
parsed: iBeaconAdvertisement,
) -> None:
# Handle iBeacon with a fixed mac address
# and or detect if the iBeacon is using a rotating mac address
# and switch to random mac tracking method
address = service_info.address
unique_id = f"{group_id}_{address}"
new = unique_id not in self._last_rssi_by_unique_id
self._last_rssi_by_unique_id[unique_id] = service_info.rssi
self._async_track_ibeacon_with_unique_address(address, group_id, unique_id)
if address not in self._unavailable_trackers:
self._unavailable_trackers[address] = bluetooth.async_track_unavailable(
self.hass, self._async_handle_unavailable, address
)
# Some manufacturers violate the spec and flood us with random
# data (sometimes its temperature data).
#
# Once we see more than MAX_IDS from the same
# address we remove all the trackers for that address and add the
# address to the ignore list since we know its garbage data.
if len(self._group_ids_by_address[address]) >= MAX_IDS:
self._async_ignore_address(address)
return
# Once we see more than MAX_IDS from the same
# group_id we remove all the trackers for that group_id
# as it means the addresses are being rotated.
if len(self._addresses_by_group_id[group_id]) >= MAX_IDS:
self._async_convert_random_mac_tracking(group_id, service_info, parsed)
return
_async_dispatch_update(self.hass, unique_id, service_info, parsed, new, True)
@callback
def _async_stop(self) -> None:
"""Stop the Coordinator."""
for cancel in self._unavailable_trackers.values():
cancel()
self._unavailable_trackers.clear()
async def _entry_updated(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
self._min_rssi = entry.options.get(CONF_MIN_RSSI) or DEFAULT_MIN_RSSI
@callback
def _async_check_unavailable_groups_with_random_macs(self) -> None:
"""Check for random mac groups that have not been seen in a while and mark them as unavailable."""
now = MONOTONIC_TIME()
gone_unavailable = [
group_id
for group_id in self._group_ids_random_macs
if group_id not in self._unavailable_group_ids
and (service_info := self._last_seen_by_group_id.get(group_id))
and now - service_info.time > UNAVAILABLE_TIMEOUT
]
for group_id in gone_unavailable:
self._unavailable_group_ids.add(group_id)
async_dispatcher_send(self.hass, signal_unavailable(group_id))
@callback
def _async_update_rssi(self) -> None:
"""Check to see if the rssi has changed and update any devices.
We don't callback on RSSI changes so we need to check them
here and send them over the dispatcher periodically to
ensure the distance calculation is update.
"""
for unique_id, rssi in self._last_rssi_by_unique_id.items():
address = unique_id.split("_")[-1]
if (
(
service_info := bluetooth.async_last_service_info(
self.hass, address, connectable=False
)
)
and service_info.rssi != rssi
and (parsed := parse(service_info))
):
async_dispatcher_send(
self.hass,
signal_seen(unique_id),
parsed,
)
@callback
def _async_update(self, _now: datetime) -> None:
"""Update the Coordinator."""
self._async_check_unavailable_groups_with_random_macs()
self._async_update_rssi()
@callback
def _async_restore_from_registry(self) -> None:
"""Restore the state of the Coordinator from the device registry."""
for device in self._dev_reg.devices.values():
unique_id = None
for identifier in device.identifiers:
if identifier[0] == DOMAIN:
unique_id = identifier[1]
break
if not unique_id:
continue
# iBeacons with a fixed MAC address
if unique_id.count("_") == 3:
uuid, major, minor, address = unique_id.split("_")
group_id = f"{uuid}_{major}_{minor}"
self._async_track_ibeacon_with_unique_address(
address, group_id, unique_id
)
# iBeacons with a random MAC address
elif unique_id.count("_") == 2:
uuid, major, minor = unique_id.split("_")
group_id = f"{uuid}_{major}_{minor}"
self._group_ids_random_macs.add(group_id)
@callback
def async_start(self) -> None:
"""Start the Coordinator."""
self._async_restore_from_registry()
entry = self._entry
entry.async_on_unload(entry.add_update_listener(self._entry_updated))
entry.async_on_unload(
bluetooth.async_register_callback(
self.hass,
self._async_update_ibeacon,
BluetoothCallbackMatcher(
connectable=False,
manufacturer_id=APPLE_MFR_ID,
manufacturer_data_start=[IBEACON_FIRST_BYTE, IBEACON_SECOND_BYTE],
), # We will take data from any source
bluetooth.BluetoothScanningMode.PASSIVE,
)
)
entry.async_on_unload(self._async_stop)
# Replay any that are already there.
for service_info in bluetooth.async_discovered_service_info(
self.hass, connectable=False
):
if is_ibeacon_service_info(service_info):
self._async_update_ibeacon(
service_info, bluetooth.BluetoothChange.ADVERTISEMENT
)
entry.async_on_unload(
async_track_time_interval(self.hass, self._async_update, UPDATE_INTERVAL)
)

View file

@ -0,0 +1,92 @@
"""Support for tracking iBeacon devices."""
from __future__ import annotations
from ibeacon_ble import iBeaconAdvertisement
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, SIGNAL_IBEACON_DEVICE_NEW
from .coordinator import IBeaconCoordinator
from .entity import IBeaconEntity
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up device tracker for iBeacon Tracker component."""
coordinator: IBeaconCoordinator = hass.data[DOMAIN]
@callback
def _async_device_new(
unique_id: str,
identifier: str,
parsed: iBeaconAdvertisement,
) -> None:
"""Signal a new device."""
async_add_entities(
[
IBeaconTrackerEntity(
coordinator,
identifier,
unique_id,
parsed,
)
]
)
entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_IBEACON_DEVICE_NEW, _async_device_new)
)
class IBeaconTrackerEntity(IBeaconEntity, BaseTrackerEntity):
"""An iBeacon Tracker entity."""
def __init__(
self,
coordinator: IBeaconCoordinator,
identifier: str,
device_unique_id: str,
parsed: iBeaconAdvertisement,
) -> None:
"""Initialize an iBeacon tracker entity."""
super().__init__(coordinator, identifier, device_unique_id, parsed)
self._attr_unique_id = device_unique_id
self._active = True
@property
def state(self) -> str:
"""Return the state of the device."""
return STATE_HOME if self._active else STATE_NOT_HOME
@property
def source_type(self) -> SourceType:
"""Return tracker source type."""
return SourceType.BLUETOOTH_LE
@property
def icon(self) -> str:
"""Return device icon."""
return "mdi:bluetooth-connect" if self._active else "mdi:bluetooth-off"
@callback
def _async_seen(
self,
parsed: iBeaconAdvertisement,
) -> None:
"""Update state."""
self._active = True
self._parsed = parsed
self.async_write_ha_state()
@callback
def _async_unavailable(self) -> None:
"""Set unavailable."""
self._active = False
self.async_write_ha_state()

View file

@ -0,0 +1,80 @@
"""Support for iBeacon device sensors."""
from __future__ import annotations
from abc import abstractmethod
from ibeacon_ble import iBeaconAdvertisement
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import ATTR_MAJOR, ATTR_MINOR, ATTR_SOURCE, ATTR_UUID, DOMAIN
from .coordinator import IBeaconCoordinator, signal_seen, signal_unavailable
class IBeaconEntity(Entity):
"""An iBeacon entity."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self,
coordinator: IBeaconCoordinator,
identifier: str,
device_unique_id: str,
parsed: iBeaconAdvertisement,
) -> None:
"""Initialize an iBeacon sensor entity."""
self._device_unique_id = device_unique_id
self._coordinator = coordinator
self._parsed = parsed
self._attr_device_info = DeviceInfo(
name=identifier,
identifiers={(DOMAIN, device_unique_id)},
)
@property
def extra_state_attributes(
self,
) -> dict[str, str | int]:
"""Return the device state attributes."""
parsed = self._parsed
return {
ATTR_UUID: str(parsed.uuid),
ATTR_MAJOR: parsed.major,
ATTR_MINOR: parsed.minor,
ATTR_SOURCE: parsed.source,
}
@abstractmethod
@callback
def _async_seen(
self,
parsed: iBeaconAdvertisement,
) -> None:
"""Update state."""
@abstractmethod
@callback
def _async_unavailable(self) -> None:
"""Set unavailable."""
async def async_added_to_hass(self) -> None:
"""Register state update callbacks."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
signal_seen(self._device_unique_id),
self._async_seen,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
signal_unavailable(self._device_unique_id),
self._async_unavailable,
)
)

View file

@ -0,0 +1,12 @@
{
"domain": "ibeacon",
"name": "iBeacon Tracker",
"documentation": "https://www.home-assistant.io/integrations/ibeacon",
"dependencies": ["bluetooth"],
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [2, 21] }],
"requirements": ["ibeacon_ble==0.6.4"],
"codeowners": ["@bdraco"],
"iot_class": "local_push",
"loggers": ["bleak"],
"config_flow": true
}

View file

@ -0,0 +1,134 @@
"""Support for iBeacon device sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from ibeacon_ble import iBeaconAdvertisement
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LENGTH_METERS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, SIGNAL_IBEACON_DEVICE_NEW
from .coordinator import IBeaconCoordinator
from .entity import IBeaconEntity
@dataclass
class IBeaconRequiredKeysMixin:
"""Mixin for required keys."""
value_fn: Callable[[iBeaconAdvertisement], int | None]
@dataclass
class IBeaconSensorEntityDescription(SensorEntityDescription, IBeaconRequiredKeysMixin):
"""Describes iBeacon sensor entity."""
SENSOR_DESCRIPTIONS = (
IBeaconSensorEntityDescription(
key="rssi",
name="Signal Strength",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
entity_registry_enabled_default=False,
value_fn=lambda parsed: parsed.rssi,
state_class=SensorStateClass.MEASUREMENT,
),
IBeaconSensorEntityDescription(
key="power",
name="Power",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
entity_registry_enabled_default=False,
value_fn=lambda parsed: parsed.power,
state_class=SensorStateClass.MEASUREMENT,
),
IBeaconSensorEntityDescription(
key="estimated_distance",
name="Estimated Distance",
icon="mdi:signal-distance-variant",
native_unit_of_measurement=LENGTH_METERS,
value_fn=lambda parsed: parsed.distance,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up sensors for iBeacon Tracker component."""
coordinator: IBeaconCoordinator = hass.data[DOMAIN]
@callback
def _async_device_new(
unique_id: str,
identifier: str,
parsed: iBeaconAdvertisement,
) -> None:
"""Signal a new device."""
async_add_entities(
IBeaconSensorEntity(
coordinator,
description,
identifier,
unique_id,
parsed,
)
for description in SENSOR_DESCRIPTIONS
)
entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_IBEACON_DEVICE_NEW, _async_device_new)
)
class IBeaconSensorEntity(IBeaconEntity, SensorEntity):
"""An iBeacon sensor entity."""
entity_description: IBeaconSensorEntityDescription
def __init__(
self,
coordinator: IBeaconCoordinator,
description: IBeaconSensorEntityDescription,
identifier: str,
device_unique_id: str,
parsed: iBeaconAdvertisement,
) -> None:
"""Initialize an iBeacon sensor entity."""
super().__init__(coordinator, identifier, device_unique_id, parsed)
self._attr_unique_id = f"{device_unique_id}_{description.key}"
self.entity_description = description
@callback
def _async_seen(
self,
parsed: iBeaconAdvertisement,
) -> None:
"""Update state."""
self._attr_available = True
self._parsed = parsed
self.async_write_ha_state()
@callback
def _async_unavailable(self) -> None:
"""Update state."""
self._attr_available = False
self.async_write_ha_state()
@property
def native_value(self) -> int | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._parsed)

View file

@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"description": "Do you want to setup iBeacon Tracker?"
}
},
"abort": {
"bluetooth_not_available": "At least one Bluetooth adapter or remote must be configured to use iBeacon Tracker.",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
"options": {
"step": {
"init": {
"description": "iBeacons with an RSSI value lower than the Minimum RSSI will be ignored. If the integration is seeing neighboring iBeacons, increasing this value may help.",
"data": {
"min_rssi": "Minimum RSSI"
}
}
}
}
}

View file

@ -0,0 +1,23 @@
{
"config": {
"abort": {
"bluetooth_not_available": "At least one Bluetooth adapter or remote must be configured to use iBeacon Tracker.",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"step": {
"user": {
"description": "Do you want to setup iBeacon Tracker?"
}
}
},
"options": {
"step": {
"init": {
"data": {
"min_rssi": "Minimum RSSI"
},
"description": "iBeacons with an RSSI value lower than the Minimum RSSI will be ignored. If the integration is seeing neighboring iBeacons, increasing this value may help."
}
}
}
}

View file

@ -115,6 +115,14 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
6,
],
},
{
"domain": "ibeacon",
"manufacturer_id": 76,
"manufacturer_data_start": [
2,
21,
],
},
{
"domain": "inkbird",
"local_name": "sps",

View file

@ -170,6 +170,7 @@ FLOWS = {
"hyperion",
"ialarm",
"iaqualink",
"ibeacon",
"icloud",
"ifttt",
"inkbird",

View file

@ -1202,6 +1202,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.ibeacon.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.image_processing.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View file

@ -892,6 +892,9 @@ iammeter==0.1.7
# homeassistant.components.iaqualink
iaqualink==0.4.1
# homeassistant.components.ibeacon
ibeacon_ble==0.6.4
# homeassistant.components.watson_tts
ibm-watson==5.2.2

View file

@ -657,6 +657,9 @@ hyperion-py==0.7.5
# homeassistant.components.iaqualink
iaqualink==0.4.1
# homeassistant.components.ibeacon
ibeacon_ble==0.6.4
# homeassistant.components.ping
icmplib==3.0

View file

@ -0,0 +1,51 @@
"""Tests for the ibeacon integration."""
from bleak.backends.device import BLEDevice
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
BLUECHARM_BLE_DEVICE = BLEDevice(
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
name="BlueCharm_177999",
)
BLUECHARM_BEACON_SERVICE_INFO = BluetoothServiceInfo(
name="BlueCharm_177999",
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
rssi=-63,
service_data={},
manufacturer_data={76: b"\x02\x15BlueCharmBeacons\x0e\xfe\x13U\xc5"},
service_uuids=[],
source="local",
)
BLUECHARM_BEACON_SERVICE_INFO_2 = BluetoothServiceInfo(
name="BlueCharm_177999",
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
rssi=-53,
manufacturer_data={76: b"\x02\x15BlueCharmBeacons\x0e\xfe\x13U\xc5"},
service_data={
"00002080-0000-1000-8000-00805f9b34fb": b"j\x0c\x0e\xfe\x13U",
"0000feaa-0000-1000-8000-00805f9b34fb": b" \x00\x0c\x00\x1c\x00\x00\x00\x06h\x00\x008\x10",
},
service_uuids=["0000feaa-0000-1000-8000-00805f9b34fb"],
source="local",
)
NO_NAME_BEACON_SERVICE_INFO = BluetoothServiceInfo(
name="61DE521B-F0BF-9F44-64D4-75BBE1738105",
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
rssi=-53,
manufacturer_data={76: b"\x02\x15NoNamearmBeacons\x0e\xfe\x13U\xc5"},
service_data={
"00002080-0000-1000-8000-00805f9b34fb": b"j\x0c\x0e\xfe\x13U",
"0000feaa-0000-1000-8000-00805f9b34fb": b" \x00\x0c\x00\x1c\x00\x00\x00\x06h\x00\x008\x10",
},
service_uuids=["0000feaa-0000-1000-8000-00805f9b34fb"],
source="local",
)
BEACON_RANDOM_ADDRESS_SERVICE_INFO = BluetoothServiceInfo(
name="RandomAddress_1234",
address="AA:BB:CC:DD:EE:00",
rssi=-63,
service_data={},
manufacturer_data={76: b"\x02\x15RandCharmBeacons\x0e\xfe\x13U\xc5"},
service_uuids=[],
source="local",
)

View file

@ -0,0 +1 @@
"""ibeacon session fixtures."""

View file

@ -0,0 +1,67 @@
"""Test the ibeacon config flow."""
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.ibeacon.const import CONF_MIN_RSSI, DOMAIN
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_setup_user_no_bluetooth(hass, mock_bluetooth_adapters):
"""Test setting up via user interaction when bluetooth is not enabled."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "bluetooth_not_available"
async def test_setup_user(hass, enable_bluetooth):
"""Test setting up via user interaction with bluetooth enabled."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch("homeassistant.components.ibeacon.async_setup_entry", return_value=True):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "iBeacon Tracker"
assert result2["data"] == {}
async def test_setup_user_already_setup(hass, enable_bluetooth):
"""Test setting up via user when already setup ."""
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "single_instance_allowed"
async def test_options_flow(hass, enable_bluetooth):
"""Test setting up via user when already setup ."""
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "init"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_MIN_RSSI: -70}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["data"] == {CONF_MIN_RSSI: -70}

View file

@ -0,0 +1,108 @@
"""Test the ibeacon sensors."""
from dataclasses import replace
import pytest
from homeassistant.components.ibeacon.const import CONF_MIN_RSSI, DOMAIN
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from . import BLUECHARM_BEACON_SERVICE_INFO
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""
async def test_many_groups_same_address_ignored(hass):
"""Test the different uuid, major, minor from many addresses removes all associated entities."""
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()
inject_bluetooth_service_info(hass, BLUECHARM_BEACON_SERVICE_INFO)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.bluecharm_177999_8105_estimated_distance") is not None
)
for i in range(12):
service_info = BluetoothServiceInfo(
name="BlueCharm_177999",
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
rssi=-63,
service_data={},
manufacturer_data={
76: b"\x02\x15BlueCharmBeacons" + bytearray([i]) + b"\xfe\x13U\xc5"
},
service_uuids=[],
source="local",
)
inject_bluetooth_service_info(hass, service_info)
await hass.async_block_till_done()
assert hass.states.get("sensor.bluecharm_177999_8105_estimated_distance") is None
async def test_ignore_anything_less_than_min_rssi(hass):
"""Test entities are not created when below the min rssi."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_MIN_RSSI: -60},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
inject_bluetooth_service_info(
hass, replace(BLUECHARM_BEACON_SERVICE_INFO, rssi=-100)
)
await hass.async_block_till_done()
assert hass.states.get("sensor.bluecharm_177999_8105_estimated_distance") is None
inject_bluetooth_service_info(
hass,
replace(
BLUECHARM_BEACON_SERVICE_INFO,
rssi=-10,
service_uuids=["0000180f-0000-1000-8000-00805f9b34fb"],
),
)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.bluecharm_177999_8105_estimated_distance") is not None
)
async def test_ignore_not_ibeacons(hass):
"""Test we ignore non-ibeacon data."""
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()
before_entity_count = len(hass.states.async_entity_ids())
inject_bluetooth_service_info(
hass,
replace(
BLUECHARM_BEACON_SERVICE_INFO, manufacturer_data={76: b"\x02\x15invalid"}
),
)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids()) == before_entity_count

View file

@ -0,0 +1,132 @@
"""Test the ibeacon device trackers."""
from dataclasses import replace
from datetime import timedelta
import time
from unittest.mock import patch
import pytest
from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS
from homeassistant.components.ibeacon.const import DOMAIN, UNAVAILABLE_TIMEOUT
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
STATE_HOME,
STATE_NOT_HOME,
STATE_UNAVAILABLE,
)
from homeassistant.util import dt as dt_util
from . import (
BEACON_RANDOM_ADDRESS_SERVICE_INFO,
BLUECHARM_BEACON_SERVICE_INFO,
BLUECHARM_BLE_DEVICE,
)
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.bluetooth import (
inject_bluetooth_service_info,
patch_all_discovered_devices,
)
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""
async def test_device_tracker_fixed_address(hass):
"""Test creating and updating device_tracker."""
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([BLUECHARM_BLE_DEVICE]):
inject_bluetooth_service_info(hass, BLUECHARM_BEACON_SERVICE_INFO)
await hass.async_block_till_done()
tracker = hass.states.get("device_tracker.bluecharm_177999_8105")
tracker_attributes = tracker.attributes
assert tracker.state == STATE_HOME
assert tracker_attributes[ATTR_FRIENDLY_NAME] == "BlueCharm_177999 8105"
with patch_all_discovered_devices([]):
await hass.async_block_till_done()
async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS * 2)
)
await hass.async_block_till_done()
tracker = hass.states.get("device_tracker.bluecharm_177999_8105")
assert tracker.state == STATE_NOT_HOME
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_device_tracker_random_address(hass):
"""Test creating and updating device_tracker."""
entry = MockConfigEntry(
domain=DOMAIN,
)
entry.add_to_hass(hass)
start_time = time.monotonic()
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
for i in range(20):
inject_bluetooth_service_info(
hass,
replace(
BEACON_RANDOM_ADDRESS_SERVICE_INFO, address=f"AA:BB:CC:DD:EE:{i:02X}"
),
)
await hass.async_block_till_done()
tracker = hass.states.get("device_tracker.randomaddress_1234")
tracker_attributes = tracker.attributes
assert tracker.state == STATE_HOME
assert tracker_attributes[ATTR_FRIENDLY_NAME] == "RandomAddress_1234"
await hass.async_block_till_done()
with patch_all_discovered_devices([]), patch(
"homeassistant.components.ibeacon.coordinator.MONOTONIC_TIME",
return_value=start_time + UNAVAILABLE_TIMEOUT + 1,
):
async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TIMEOUT)
)
await hass.async_block_till_done()
tracker = hass.states.get("device_tracker.randomaddress_1234")
assert tracker.state == STATE_NOT_HOME
inject_bluetooth_service_info(
hass, replace(BEACON_RANDOM_ADDRESS_SERVICE_INFO, address="AA:BB:CC:DD:EE:DD")
)
await hass.async_block_till_done()
tracker = hass.states.get("device_tracker.randomaddress_1234")
tracker_attributes = tracker.attributes
assert tracker.state == STATE_HOME
assert tracker_attributes[ATTR_FRIENDLY_NAME] == "RandomAddress_1234"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
tracker = hass.states.get("device_tracker.randomaddress_1234")
tracker_attributes = tracker.attributes
assert tracker.state == STATE_UNAVAILABLE
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
tracker = hass.states.get("device_tracker.randomaddress_1234")
tracker_attributes = tracker.attributes
assert tracker.state == STATE_HOME
assert tracker_attributes[ATTR_FRIENDLY_NAME] == "RandomAddress_1234"

View file

@ -0,0 +1,184 @@
"""Test the ibeacon sensors."""
from dataclasses import replace
from datetime import timedelta
import pytest
from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS
from homeassistant.components.ibeacon.const import DOMAIN, UPDATE_INTERVAL
from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
)
from homeassistant.util import dt as dt_util
from . import (
BLUECHARM_BEACON_SERVICE_INFO,
BLUECHARM_BEACON_SERVICE_INFO_2,
BLUECHARM_BLE_DEVICE,
NO_NAME_BEACON_SERVICE_INFO,
)
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.bluetooth import (
inject_bluetooth_service_info,
patch_all_discovered_devices,
)
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""
async def test_sensors_updates_fixed_mac_address(hass):
"""Test creating and updating sensors with a fixed mac address."""
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([BLUECHARM_BLE_DEVICE]):
inject_bluetooth_service_info(hass, BLUECHARM_BEACON_SERVICE_INFO)
await hass.async_block_till_done()
distance_sensor = hass.states.get("sensor.bluecharm_177999_8105_estimated_distance")
distance_attributes = distance_sensor.attributes
assert distance_sensor.state == "2"
assert (
distance_attributes[ATTR_FRIENDLY_NAME]
== "BlueCharm_177999 8105 Estimated Distance"
)
assert distance_attributes[ATTR_UNIT_OF_MEASUREMENT] == "m"
assert distance_attributes[ATTR_STATE_CLASS] == "measurement"
with patch_all_discovered_devices([BLUECHARM_BLE_DEVICE]):
inject_bluetooth_service_info(hass, BLUECHARM_BEACON_SERVICE_INFO_2)
await hass.async_block_till_done()
distance_sensor = hass.states.get("sensor.bluecharm_177999_8105_estimated_distance")
distance_attributes = distance_sensor.attributes
assert distance_sensor.state == "0"
assert (
distance_attributes[ATTR_FRIENDLY_NAME]
== "BlueCharm_177999 8105 Estimated Distance"
)
assert distance_attributes[ATTR_UNIT_OF_MEASUREMENT] == "m"
assert distance_attributes[ATTR_STATE_CLASS] == "measurement"
# Make sure RSSI updates are picked up by the periodic update
inject_bluetooth_service_info(
hass, replace(BLUECHARM_BEACON_SERVICE_INFO_2, rssi=-84)
)
# We should not see it right away since the update interval is 60 seconds
distance_sensor = hass.states.get("sensor.bluecharm_177999_8105_estimated_distance")
distance_attributes = distance_sensor.attributes
assert distance_sensor.state == "0"
with patch_all_discovered_devices([BLUECHARM_BLE_DEVICE]):
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=UPDATE_INTERVAL.total_seconds() * 2),
)
await hass.async_block_till_done()
distance_sensor = hass.states.get("sensor.bluecharm_177999_8105_estimated_distance")
distance_attributes = distance_sensor.attributes
assert distance_sensor.state == "14"
assert (
distance_attributes[ATTR_FRIENDLY_NAME]
== "BlueCharm_177999 8105 Estimated Distance"
)
assert distance_attributes[ATTR_UNIT_OF_MEASUREMENT] == "m"
assert distance_attributes[ATTR_STATE_CLASS] == "measurement"
with patch_all_discovered_devices([]):
await hass.async_block_till_done()
async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS * 2)
)
await hass.async_block_till_done()
distance_sensor = hass.states.get("sensor.bluecharm_177999_8105_estimated_distance")
assert distance_sensor.state == STATE_UNAVAILABLE
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_sensor_with_no_local_name(hass):
"""Test creating and updating sensors."""
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()
inject_bluetooth_service_info(hass, NO_NAME_BEACON_SERVICE_INFO)
await hass.async_block_till_done()
assert (
hass.states.get(
"sensor.4e6f4e61_6d65_6172_6d42_6561636f6e73_3838_4949_8105_estimated_distance"
)
is not None
)
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_sensor_sees_last_service_info(hass):
"""Test sensors are created from recent history."""
entry = MockConfigEntry(
domain=DOMAIN,
)
entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, BLUECHARM_BEACON_SERVICE_INFO)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.bluecharm_177999_8105_estimated_distance").state == "2"
)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_can_unload_and_reload(hass):
"""Test sensors get recreated on unload/setup."""
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()
inject_bluetooth_service_info(hass, BLUECHARM_BEACON_SERVICE_INFO)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.bluecharm_177999_8105_estimated_distance").state == "2"
)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.bluecharm_177999_8105_estimated_distance").state
== STATE_UNAVAILABLE
)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.bluecharm_177999_8105_estimated_distance").state == "2"
)