diff --git a/README.md b/README.md index 0a9685a..1505eed 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Bermuda aims to let you track any bluetooth device, and have Homeassistant tell - Area-based device location (ie, device-level room prescence) is working reasonably well. - Creates sensors for Area and Distance for devices you choose - Supports iBeacon devices, including those with randomised MAC addresses (like Android phones running HA Companion App) +- Supports IRK (resolvable keys) via the [Private BLE Device](https://www.home-assistant.io/integrations/private_ble_device/) core component. Once your iOS device (or Android!) is set up in Private BLE Device, it will automatically receive Bermuda sensors as well! - Creates `device_tracker` entities for chosen devices, which can be linked to "Person"s for Home/Not Home tracking - Configurable settings for rssi reference level, environmental attenuation, max tracking radius - Provides a comprehensive json/yaml dump of devices and their distances from each bluetooth @@ -51,13 +52,6 @@ Bermuda aims to let you track any bluetooth device, and have Homeassistant tell out a bit and get a workable impression of where devices and scanners are in relation to each other. Math / Geometry geeks are very welcome to assist, I am well out of my depth here! -- As yet it doesn't know how to handle iPhones with their rotating MAC addresses, hopefully - we can integrate with [Private BLE Device](https://www.home-assistant.io/integrations/private_ble_device/) - to solve that. We do now support iBeacon, so companion apps such as the one for Android - will now work, even with the rotating MAC-address. iBeacon apps on iOS behave oddly when - backgrounded (an iOS-enforced oddity), so we don't support that either currently. We will - have Private BLE working at some point though, watch this space. - ## What you won't need (if this works for you) - Bermuda provides equivalent functionality to `bluetooth_ble_tracker` by @@ -144,10 +138,13 @@ for any person/user. ### Can I track my phone? -- Android: Yes! iPhone: Soon!? Bermuda now supports the iBeacon format, so if you can get your phone - to broadcast iBeacon packets, then yes. The Homeassistant comanion app for - Android does, so it works well. - iPhone will be supported soon by tying in to the `Private BLE Device` integration. +- Yes! Both Android and iOS devices (iPhone, iPad) are supported. iWatch should + also work, providing you can get them showing up in the + [Private BLE Device](https://www.home-assistant.io/integrations/private_ble_device/) + core integration. + +- Android: you have the option of using IRK (Private BLE Device) or iBeacon support + via the HA Companion app. - Bermuda's iBeacon support is rather simplistic and opinionated, reflecting the author somewhat. @@ -170,8 +167,12 @@ for any person/user. ### Why do my bluetooth devices have only the address and no name? -- you can tell your bluetooth proxies to send an inquiry in response to - advertisements, this _might_ cause names to show up. +- You can simply rename your entities if you like. + +- You can also tell your bluetooth proxies to send an inquiry in response to + advertisements, this _might_ cause names to show up. Consider also though + that it means while your proxy is asking a device for its name it can't be + listening for BLE traffic to proxy. In esphome, this is done by adding `active: true` to the `esp32_ble_tracker` section (this is separate from the active property of the `bluetooth_proxy` section, which controls outbound client connections). @@ -216,7 +217,9 @@ So how does that help? - There are three main factors. - How often your beacon transmits advertisements. Most are less than 2 seconds. - - Bermuda only checks for new advertisements every second. + - Bermuda only checks for new advertisements every second. It will update sensors + immediately if devices get "closer", but it is more leisurely for devices that + appear to be "leaving" an area. - The proxies might not catch every advertisement. In my esphome proxies I usually use these settings to ensure we don't miss toooo many: ```yaml diff --git a/custom_components/bermuda/__init__.py b/custom_components/bermuda/__init__.py index 83457c3..4cfd729 100644 --- a/custom_components/bermuda/__init__.py +++ b/custom_components/bermuda/__init__.py @@ -21,11 +21,16 @@ from homeassistant.const import STATE_NOT_HOME from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import Config +from homeassistant.core import Event from homeassistant.core import HomeAssistant from homeassistant.core import SupportsResponse +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import area_registry from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -37,6 +42,8 @@ from .const import BEACON_IBEACON_DEVICE from .const import BEACON_IBEACON_SOURCE from .const import BEACON_NOT_A_BEACON +from .const import BEACON_PRIVATE_BLE_DEVICE +from .const import BEACON_PRIVATE_BLE_SOURCE from .const import CONF_ATTENUATION from .const import CONF_DEVICES from .const import CONF_DEVTRACK_TIMEOUT @@ -53,8 +60,10 @@ from .const import DEFAULT_REF_POWER from .const import DEFAULT_SMOOTHING_SAMPLES from .const import DEFAULT_UPDATE_INTERVAL +from .const import DEVICE_TRACKER from .const import DISTANCE_TIMEOUT from .const import DOMAIN +from .const import DOMAIN_PRIVATE_BLE_DEVICE from .const import HIST_KEEP_COUNT from .const import PLATFORMS from .const import SIGNAL_DEVICE_NEW @@ -198,7 +207,8 @@ def update_advertisement( if hasattr(scandata.scanner, "_discovered_device_timestamps"): # Found a remote scanner which has timestamp history... self.scanner_sends_stamps = True - # FIXME: Doesn't appear to be any API to get this otherwise... + # There's no API for this, so we somewhat sneakily are accessing + # what is intended to be a protected dict. # pylint: disable-next=protected-access stamps = scandata.scanner._discovered_device_timestamps @@ -396,7 +406,7 @@ def calculate_data(self): self.hist_velocity.insert(0, velocity) if velocity > self.options.get(CONF_MAX_VELOCITY): - if self.parent_device.upper() in self.options[CONF_DEVICES]: + if self.parent_device.upper() in self.options.get(CONF_DEVICES, []): _LOGGER.debug( "This sparrow %s flies too fast (%2fm/s), ignoring", self.parent_device, @@ -644,6 +654,43 @@ def __init__( # forcing a scan of the captured info. self._do_full_scanner_init = True + # First time go through the private ble devices to see if there's + # any there for us to track. + self._do_private_device_init = True + + @callback + def handle_devreg_changes(ev: Event): + """Update our scanner list if the device registry is changed. + + This catches area changes (on scanners) and any new/changed + Private BLE Devices.""" + # We could try filtering on "updates" and "area" but I doubt + # this will fire all that often, and even when it does the difference + # in cycle time appears to be less than 1ms. + _LOGGER.debug( + "Device registry has changed, we will reload scanners and Private BLE Devs. ev: %s", + ev, + ) + # Mark so that we will rebuild scanner list on next update cycle. + self._do_full_scanner_init = True + # Same with Private BLE Device entities + self._do_private_device_init = True + + # Let's kick off a scanner and private_ble_device scan/refresh/init + self._refresh_scanners([], self._do_full_scanner_init) + self.configure_beacons() + + # If there are no `CONFIGURED_DEVICES` and the user only has private_ble_devices + # in their setup, then we might have done our init runs before that integration + # was up - in which case we'll get device registry changes. We should kick off + # the update in case it's not running yet (because of no subscribers yet being + # attached to the dataupdatecoordinator). + self.hass.add_job(self._async_update_data()) + + # Listen for changes to the device registry and handle them. + # Primarily for when scanners get moved to a different area. + hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, handle_devreg_changes) + self.options = {} # TODO: This is only here because we haven't set up migration of config @@ -718,7 +765,7 @@ def device_tracker_created(self, address): def _get_device(self, address: str) -> BermudaDevice: """Search for a device entry based on mac address""" - mac = format_mac(address) + mac = format_mac(address).lower() # format_mac tries to return a lower-cased, colon-separated mac address. # failing that, it returns the original unaltered. if mac in self.devices: @@ -728,7 +775,7 @@ def _get_device(self, address: str) -> BermudaDevice: def _get_or_create_device(self, address: str) -> BermudaDevice: device = self._get_device(address) if device is None: - mac = format_mac(address) + mac = format_mac(address).lower() self.devices[mac] = device = BermudaDevice( address=mac, options=self.options ) @@ -877,6 +924,8 @@ async def _async_update_data(self): if scanner_device is None: # The receiver doesn't have a device entry yet, let's refresh # all of them in this batch... + self._do_full_scanner_init = True + self._do_private_device_init = True self._refresh_scanners(matched_scanners, self._do_full_scanner_init) self._do_full_scanner_init = False scanner_device = self._get_device(discovered.scanner.source) @@ -897,8 +946,8 @@ async def _async_update_data(self): # END of per-advertisement-by-device loop - # Scanner entries have been loaded up with latest data, now we can process data for all devices - # over all scanners. + # Scanner entries have been loaded up with latest data, now we can + # process data for all devices over all scanners. for device in self.devices.values(): # Recalculate smoothed distances, last_seen etc device.calculate_data() @@ -921,30 +970,61 @@ async def _async_update_data(self): # so let's ensure any devices that we create sensors for are set up ready to go. # We don't do this sooner because we need to ensure we have every active scanner # already loaded up. - for address in self.options.get(CONF_DEVICES, []): - device = self._get_device(format_mac(address.lower())) - if device is not None: + for address, device in self.devices.items(): + if device.create_sensor: if not device.create_sensor_done or not device.create_tracker_done: - _LOGGER.debug( - "Firing device_new for %s (%s)", device.name, device.address - ) + _LOGGER.debug("Firing device_new for %s (%s)", device.name, address) async_dispatcher_send( - self.hass, SIGNAL_DEVICE_NEW, device.address, self.scanner_list + self.hass, SIGNAL_DEVICE_NEW, address, self.scanner_list ) # end of async update def configure_beacons(self): - """Create iBeacon and other meta-devices from the received advertisements + """Create iBeacon, Private_BLE and other meta-devices from the received advertisements Note that at this point all the distances etc should be fresh for the source devices, so we can just copy values from them to the beacon metadevice. """ + entreg = er.async_get(self.hass) + devreg = dr.async_get(self.hass) + + # ### Seed the Private BLE Device entries from the other integration + if self._do_private_device_init: + # Iterate through the Private BLE Device integration's entities, + # and ensure for each "device" we create a source device. + self._do_private_device_init = False + _LOGGER.debug("Refreshing Private BLE Device list") + for entity_id in self.hass.states.async_entity_ids(DEVICE_TRACKER): + # The device_tracker entity in private ble has no suffix on its unique_id. + if f"{DEVICE_TRACKER}.{DOMAIN_PRIVATE_BLE_DEVICE}" in entity_id: + # We have a private_ble_device to track! + pb_entity = entreg.async_get(entity_id) + pb_device = devreg.async_get(pb_entity.device_id) + pb_state = self.hass.states.get(entity_id) + pb_address = pb_state.attributes.get( + "current_address", "broken_address" + ).lower() + if pb_address: + pble_source_device = self._get_or_create_device(pb_address) + pble_source_device.name = ( + pb_device.name_by_user or pb_device.name + ) + pble_source_device.prefname = ( + pb_device.name_by_user or pb_device.name + ) + pble_source_device.beacon_type = BEACON_PRIVATE_BLE_SOURCE + pble_source_device.beacon_unique_id = pb_entity.unique_id + # First let's find the freshest device advert for each Beacon unique_id + # Start keeping a winners-list by beacon/pb id freshest_beacon_sources: dict[str, BermudaDevice] = {} + + # Iterate through each device to see if it has an advert for our target id for device in self.devices.values(): - if device.beacon_type == BEACON_IBEACON_SOURCE: + if device.beacon_type in [BEACON_IBEACON_SOURCE, BEACON_PRIVATE_BLE_SOURCE]: + # We found an advert for our beacon of interest... if ( device.beacon_unique_id not in freshest_beacon_sources # first-find or device.last_seen @@ -955,12 +1035,20 @@ def configure_beacons(self): # then we are the freshest! freshest_beacon_sources[device.beacon_unique_id] = device + # We now have a dict of the freshest device for each beacon/pb id. # Now let's go through the freshest adverts and set up those beacons. for beacon_unique_id, device in freshest_beacon_sources.items(): # Copy this device's info to the meta-device for tracking the beacon metadev = self._get_or_create_device(beacon_unique_id) - metadev.beacon_type = BEACON_IBEACON_DEVICE + if device.beacon_type == BEACON_IBEACON_SOURCE: + metadev.beacon_type = BEACON_IBEACON_DEVICE + elif device.beacon_type == BEACON_PRIVATE_BLE_SOURCE: + metadev.beacon_type = BEACON_PRIVATE_BLE_DEVICE + else: + _LOGGER.warning( + "Invalid beacon type for freshest beacon: %s", device.beacon_type + ) # anything that isn't already set to something interesting, overwrite # it with the new device's data. @@ -1010,7 +1098,16 @@ def configure_beacons(self): if device.last_seen > metadev.last_seen: metadev.last_seen = device.last_seen elif device.last_seen < metadev.last_seen: - _LOGGER.warning("Using freshest advert but it's still too old!") + # FIXME: This is showing up for some people + # (see https://github.com/agittins/bermuda/issues/138) + # but I can't see why. Downgrading to debug since it + # doesn't seem to affect ops, and adding + # params so I can perhaps get more info later. + _LOGGER.debug( + "Using freshest advert from %s for %s but it's still too old!", + device.name, + metadev.name, + ) # else there's no newer advert if device.address not in metadev.beacon_sources: @@ -1020,7 +1117,13 @@ def configure_beacons(self): del metadev.beacon_sources[HIST_KEEP_COUNT:] # Check if we should set up sensors for this beacon - if metadev.address.upper() in self.options.get(CONF_DEVICES, []): + if ( + # iBeacons need to be specifically enabled: + metadev.address.upper() in self.options.get(CONF_DEVICES, []) + # But Private BLE Devices have already been deliberately configured + # by the user, so we always just enable them + or metadev.beacon_type == BEACON_PRIVATE_BLE_DEVICE + ): # This is a meta-device we track. Flag it for set-up: metadev.create_sensor = True @@ -1105,14 +1208,14 @@ def _refresh_scanners( update_scannerlist = False for scanner in scanners: - addresses.add(scanner.scanner.source.upper()) + addresses.add(scanner.scanner.source.lower()) # If we are doing a full scan, add all the known # scanner addresses to the list, since that will cover # the scanners that have been restored from config.data if do_full_scan: for address in self.scanner_list: - addresses.add(address) + addresses.add(address.lower()) if len(addresses) > 0: # FIXME: Really? This can't possibly be a sensible nesting of loops. @@ -1121,7 +1224,7 @@ def _refresh_scanners( for dev_entry in self.hass.data["device_registry"].devices.data.values(): for dev_connection in dev_entry.connections: if dev_connection[0] in ["mac", "bluetooth"]: - found_address = dev_connection[1].upper() + found_address = format_mac(dev_connection[1]) if found_address in addresses: scandev = self._get_device(found_address) if scandev is None: @@ -1129,6 +1232,8 @@ def _refresh_scanners( _LOGGER.debug("New Scanner: %s", found_address) update_scannerlist = True scandev = self._get_or_create_device(found_address) + # Found the device entry and have created our scannerdevice, + # now update any fields that might be new from the device reg: scandev_orig = scandev scandev.area_id = dev_entry.area_id scandev.entry_id = dev_entry.id @@ -1145,6 +1250,8 @@ def _refresh_scanners( scandev.name, ) scandev.is_scanner = True + # If the scanner data we loaded from our saved data appears + # out of date, trigger a full rescan of seen scanners. if scandev_orig != scandev: # something changed, let's update the saved list. _LOGGER.debug( diff --git a/custom_components/bermuda/const.py b/custom_components/bermuda/const.py index 23eb0a7..6ed2400 100644 --- a/custom_components/bermuda/const.py +++ b/custom_components/bermuda/const.py @@ -32,6 +32,9 @@ # PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] PLATFORMS = [SENSOR, DEVICE_TRACKER] +# Should probably retreive this from the component, but it's in "DOMAIN" *shrug* +DOMAIN_PRIVATE_BLE_DEVICE = "private_ble_device" + # Signal names we are using: SIGNAL_DEVICE_NEW = f"{DOMAIN}-device-new" @@ -56,6 +59,12 @@ BEACON_IBEACON_DEVICE: Final = ( "beacon device" # The meta-device created to track the beacon ) +BEACON_PRIVATE_BLE_SOURCE: Final = ( + "private_ble_src" # current (random) MAC of a private ble device +) +BEACON_PRIVATE_BLE_DEVICE: Final = ( + "private_ble_device" # meta-device create to track private ble device +) DOCS = {} diff --git a/custom_components/bermuda/entity.py b/custom_components/bermuda/entity.py index 1ea8a52..d10ba4f 100644 --- a/custom_components/bermuda/entity.py +++ b/custom_components/bermuda/entity.py @@ -14,9 +14,11 @@ from .const import ATTRIBUTION from .const import BEACON_IBEACON_DEVICE +from .const import BEACON_PRIVATE_BLE_DEVICE from .const import CONF_UPDATE_INTERVAL from .const import DEFAULT_UPDATE_INTERVAL from .const import DOMAIN +from .const import DOMAIN_PRIVATE_BLE_DEVICE if TYPE_CHECKING: from . import BermudaDataUpdateCoordinator @@ -42,6 +44,8 @@ def __init__( self.config_entry = config_entry self._device = coordinator.devices[address] self.area_reg = area_registry.async_get(coordinator.hass) + self.devreg = dr.async_get(coordinator.hass) + self.bermuda_update_interval = config_entry.options.get( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL ) @@ -96,20 +100,39 @@ def device_info(self): # For scanners we use ethernet MAC, which looks like they are # normally stored lowercased, otherwise we use our btmac, which # seem to be stored uppercased. + # existing_device_id = None + domain_name = DOMAIN + if self._device.is_scanner: connection = {(dr.CONNECTION_NETWORK_MAC, self._device.address.lower())} elif self._device.beacon_type == BEACON_IBEACON_DEVICE: # ibeacon doesn't (yet) actually set a connection, but # this "matches" what it stores for identifier. connection = {("ibeacon", self._device.address.lower())} + elif self._device.beacon_type == BEACON_PRIVATE_BLE_DEVICE: + # Private BLE Device integration doesn't specify "connection" tuples, + # so we use what it defines for the "connection" instead. + connection = {("private_ble_device", self._device.address.lower())} + # We look up and use the device from the registry so we get + # the private_ble_device device congealment! + # The "connection" is actually being used as the "identifiers" tuple + # here. + # dr_device = self.devreg.async_get_device(connection) + # if dr_device is not None: + # existing_device_id = dr_device.id + domain_name = DOMAIN_PRIVATE_BLE_DEVICE else: connection = {(dr.CONNECTION_BLUETOOTH, self._device.address.upper())} - return { - "identifiers": {(DOMAIN, self._device.unique_id)}, + device_info = { + "identifiers": {(domain_name, self._device.unique_id)}, "connections": connection, "name": self._device.prefname, } + # if existing_device_id is not None: + # device_info['id'] = existing_device_id + + return device_info @property def device_state_attributes(self): diff --git a/custom_components/bermuda/sensor.py b/custom_components/bermuda/sensor.py index 3c60967..fce6f9a 100644 --- a/custom_components/bermuda/sensor.py +++ b/custom_components/bermuda/sensor.py @@ -20,6 +20,7 @@ from . import BermudaDataUpdateCoordinator from .const import BEACON_IBEACON_DEVICE +from .const import BEACON_PRIVATE_BLE_DEVICE from .const import DOMAIN from .const import SIGNAL_DEVICE_NEW from .entity import BermudaEntity @@ -38,7 +39,7 @@ async def async_setup_entry( created_devices = [] # list of already-created devices @callback - def device_new(address: str, scanners: [str]) -> None: + def device_new(address: str, scanners: list[str]) -> None: """Create entities for newly-found device Called from the data co-ordinator when it finds a new device that needs @@ -122,7 +123,10 @@ def device_class(self): @property def extra_state_attributes(self) -> Mapping[str, Any] | None: current_mac = self._device.address - if self._device.beacon_type == BEACON_IBEACON_DEVICE: + if self._device.beacon_type in [ + BEACON_IBEACON_DEVICE, + BEACON_PRIVATE_BLE_DEVICE, + ]: if len(self._device.beacon_sources) > 0: current_mac = self._device.beacon_sources[0] else: