Add iBeacon Tracker integration (#78671)
This commit is contained in:
parent
12856dea05
commit
bb78d52f34
23 changed files with 1444 additions and 0 deletions
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
24
homeassistant/components/ibeacon/__init__.py
Normal file
24
homeassistant/components/ibeacon/__init__.py
Normal 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
|
74
homeassistant/components/ibeacon/config_flow.py
Normal file
74
homeassistant/components/ibeacon/config_flow.py
Normal 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)
|
33
homeassistant/components/ibeacon/const.py
Normal file
33
homeassistant/components/ibeacon/const.py
Normal 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
|
378
homeassistant/components/ibeacon/coordinator.py
Normal file
378
homeassistant/components/ibeacon/coordinator.py
Normal 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)
|
||||
)
|
92
homeassistant/components/ibeacon/device_tracker.py
Normal file
92
homeassistant/components/ibeacon/device_tracker.py
Normal 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()
|
80
homeassistant/components/ibeacon/entity.py
Normal file
80
homeassistant/components/ibeacon/entity.py
Normal 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,
|
||||
)
|
||||
)
|
12
homeassistant/components/ibeacon/manifest.json
Normal file
12
homeassistant/components/ibeacon/manifest.json
Normal 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
|
||||
}
|
134
homeassistant/components/ibeacon/sensor.py
Normal file
134
homeassistant/components/ibeacon/sensor.py
Normal 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)
|
23
homeassistant/components/ibeacon/strings.json
Normal file
23
homeassistant/components/ibeacon/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
23
homeassistant/components/ibeacon/translations/en.json
Normal file
23
homeassistant/components/ibeacon/translations/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -170,6 +170,7 @@ FLOWS = {
|
|||
"hyperion",
|
||||
"ialarm",
|
||||
"iaqualink",
|
||||
"ibeacon",
|
||||
"icloud",
|
||||
"ifttt",
|
||||
"inkbird",
|
||||
|
|
10
mypy.ini
10
mypy.ini
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
51
tests/components/ibeacon/__init__.py
Normal file
51
tests/components/ibeacon/__init__.py
Normal 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",
|
||||
)
|
1
tests/components/ibeacon/conftest.py
Normal file
1
tests/components/ibeacon/conftest.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""ibeacon session fixtures."""
|
67
tests/components/ibeacon/test_config_flow.py
Normal file
67
tests/components/ibeacon/test_config_flow.py
Normal 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}
|
108
tests/components/ibeacon/test_coordinator.py
Normal file
108
tests/components/ibeacon/test_coordinator.py
Normal 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
|
132
tests/components/ibeacon/test_device_tracker.py
Normal file
132
tests/components/ibeacon/test_device_tracker.py
Normal 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"
|
184
tests/components/ibeacon/test_sensor.py
Normal file
184
tests/components/ibeacon/test_sensor.py
Normal 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"
|
||||
)
|
Loading…
Add table
Add a link
Reference in a new issue