diff --git a/.strict-typing b/.strict-typing
index 0905c17232b75d9320e831670f4b7d7a5315de26..c9dc14967cb3f0fafbc42d95c4f2920383db8b09 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -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.*
diff --git a/CODEOWNERS b/CODEOWNERS
index 314c43f418e147c88f53406effc5717b89e6dc8e..c087599baa44753df22661751396ca6b643a7e3f 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -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
diff --git a/homeassistant/components/ibeacon/__init__.py b/homeassistant/components/ibeacon/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..bf618c4ca1209281d39c1764062a6ca4301b9df5
--- /dev/null
+++ b/homeassistant/components/ibeacon/__init__.py
@@ -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
diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py
new file mode 100644
index 0000000000000000000000000000000000000000..0cfe425f1f9840674e70509d6b94105517caf1b2
--- /dev/null
+++ b/homeassistant/components/ibeacon/config_flow.py
@@ -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)
diff --git a/homeassistant/components/ibeacon/const.py b/homeassistant/components/ibeacon/const.py
new file mode 100644
index 0000000000000000000000000000000000000000..2a8b760c8d338cc81d105db8bca69e4ed3d88841
--- /dev/null
+++ b/homeassistant/components/ibeacon/const.py
@@ -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
diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py
new file mode 100644
index 0000000000000000000000000000000000000000..8c256e5a5f0ef82ff551a7aa5c6319d49eecb1c9
--- /dev/null
+++ b/homeassistant/components/ibeacon/coordinator.py
@@ -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)
+        )
diff --git a/homeassistant/components/ibeacon/device_tracker.py b/homeassistant/components/ibeacon/device_tracker.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2db3bd291ff8f7b6b84c6c9990da2a7bf1ca54e
--- /dev/null
+++ b/homeassistant/components/ibeacon/device_tracker.py
@@ -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()
diff --git a/homeassistant/components/ibeacon/entity.py b/homeassistant/components/ibeacon/entity.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ce64fb8535249e88705d740328ba4e0771c1a2e
--- /dev/null
+++ b/homeassistant/components/ibeacon/entity.py
@@ -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,
+            )
+        )
diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..531daed00f8499a079302c38cb683879b8b90b5d
--- /dev/null
+++ b/homeassistant/components/ibeacon/manifest.json
@@ -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
+}
diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py
new file mode 100644
index 0000000000000000000000000000000000000000..d3468fbc3dcc003eeb5372d817963ac6df683127
--- /dev/null
+++ b/homeassistant/components/ibeacon/sensor.py
@@ -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)
diff --git a/homeassistant/components/ibeacon/strings.json b/homeassistant/components/ibeacon/strings.json
new file mode 100644
index 0000000000000000000000000000000000000000..e2a1ab8393fb99fbd4151c76d941bfe6e070c046
--- /dev/null
+++ b/homeassistant/components/ibeacon/strings.json
@@ -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"
+        }
+      }
+    }
+  }
+}
diff --git a/homeassistant/components/ibeacon/translations/en.json b/homeassistant/components/ibeacon/translations/en.json
new file mode 100644
index 0000000000000000000000000000000000000000..1125e778b197609e6dc910cec41735e417b22f85
--- /dev/null
+++ b/homeassistant/components/ibeacon/translations/en.json
@@ -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."
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py
index a97f50f2e5f8f2759144b2f4bc476c1d7662dc51..ed98af896ffae42d48524c0c6656965107eaba2b 100644
--- a/homeassistant/generated/bluetooth.py
+++ b/homeassistant/generated/bluetooth.py
@@ -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",
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 81f7f91afb6fd9bf837f1003ac4b46cc569086b5..208a7e02efc8a92fd8128bb8b828c850147d68fd 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -170,6 +170,7 @@ FLOWS = {
         "hyperion",
         "ialarm",
         "iaqualink",
+        "ibeacon",
         "icloud",
         "ifttt",
         "inkbird",
diff --git a/mypy.ini b/mypy.ini
index 9308ce22c52950a6cfd52f7c7463885dac10d7a8..70f45c24f6214b97be9b74c52e45e6b9ecac935d 100644
--- a/mypy.ini
+++ b/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
diff --git a/requirements_all.txt b/requirements_all.txt
index 67ef23cebdbc11cc63351dc782a3ad1ed53f5698..6a12a38fdc5a4f35597c504b97e8022e240f8e95 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -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
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 1ab399f1112a95500d71f9fc094066a6927ae3cd..d60c9146079d31b41995e810d01b12a1cb840ff7 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -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
 
diff --git a/tests/components/ibeacon/__init__.py b/tests/components/ibeacon/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f9b2c1576ad22fc8fd3e89cfae02d5f08afb7454
--- /dev/null
+++ b/tests/components/ibeacon/__init__.py
@@ -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",
+)
diff --git a/tests/components/ibeacon/conftest.py b/tests/components/ibeacon/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..655c74ec488628bbc70b780b53d32e6914e64d05
--- /dev/null
+++ b/tests/components/ibeacon/conftest.py
@@ -0,0 +1 @@
+"""ibeacon session fixtures."""
diff --git a/tests/components/ibeacon/test_config_flow.py b/tests/components/ibeacon/test_config_flow.py
new file mode 100644
index 0000000000000000000000000000000000000000..bab465e5d75e985e27e71bd974402f155a58d087
--- /dev/null
+++ b/tests/components/ibeacon/test_config_flow.py
@@ -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}
diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py
new file mode 100644
index 0000000000000000000000000000000000000000..d8981de8fa95bb10539818de1fd26d9a3ce8596f
--- /dev/null
+++ b/tests/components/ibeacon/test_coordinator.py
@@ -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
diff --git a/tests/components/ibeacon/test_device_tracker.py b/tests/components/ibeacon/test_device_tracker.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3520f835ca4b41da17f1534e59a1487819f2518
--- /dev/null
+++ b/tests/components/ibeacon/test_device_tracker.py
@@ -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"
diff --git a/tests/components/ibeacon/test_sensor.py b/tests/components/ibeacon/test_sensor.py
new file mode 100644
index 0000000000000000000000000000000000000000..38c03b0be5d204be8d907eeb01c3c1e0d6fa35be
--- /dev/null
+++ b/tests/components/ibeacon/test_sensor.py
@@ -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"
+    )