Skip to content

Commit

Permalink
(feat) Per-proxy distance sensors (#107)
Browse files Browse the repository at this point in the history
- Added a distance sensor for each proxy on each tracked device.
- fixed defaults for radius and timeout not applying correctly
- fixed initialisation of sensors at system start, they are now created via callback from the data processor.
- Implemented method for sensor platform to positively indicate the sensors were created (`sensor_created`)
- Perform full re-scan of restored scanners to catch any changes since last save.
- initialise zone as STATE_UNAVAILABLE
- fix unique-id mappings and pin them to mac address more directly instead of inheriting from the parent classes.
- implement `unit_of_measurements` to get distances appearing as graphable more cleanly than the native property did.
  • Loading branch information
agittins authored Feb 27, 2024
1 parent 655aa38 commit 71a7018
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 51 deletions.
173 changes: 135 additions & 38 deletions custom_components/bermuda/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_HOME
from homeassistant.const import STATE_NOT_HOME
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback
from homeassistant.core import Config
from homeassistant.core import HomeAssistant
Expand All @@ -33,6 +34,7 @@
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import slugify
from homeassistant.util.dt import get_age
Expand All @@ -44,15 +46,16 @@
from .const import CONF_DEVTRACK_TIMEOUT
from .const import CONF_MAX_RADIUS
from .const import CONF_REF_POWER
from .const import CONFDATA_SCANNERS
from .const import DEFAULT_ATTENUATION
from .const import DEFAULT_DEVTRACK_TIMEOUT
from .const import DEFAULT_MAX_RADIUS
from .const import DEFAULT_REF_POWER
from .const import DOMAIN
from .const import HIST_KEEP_COUNT
from .const import PLATFORMS
from .const import SIGNAL_DEVICE_NEW
from .const import STARTUP_MESSAGE
from .entity import BermudaEntity

# from bthome_ble import BTHomeBluetoothDeviceData

Expand All @@ -79,9 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
hass.data.setdefault(DOMAIN, {})
_LOGGER.info(STARTUP_MESSAGE)

# username = entry.data.get(CONF_USERNAME)
# password = entry.data.get(CONF_PASSWORD)

coordinator = BermudaDataUpdateCoordinator(hass, entry)
await coordinator.async_refresh()

Expand All @@ -91,11 +91,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
hass.data[DOMAIN][entry.entry_id] = coordinator

for platform in PLATFORMS:
if entry.options.get(platform, True):
coordinator.platforms.append(platform)
hass.async_add_job(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
coordinator.platforms.append(platform)
hass.async_add_job(
hass.config_entries.async_forward_entry_setup(entry, platform)
)

entry.add_update_listener(async_reload_entry)
return True
Expand Down Expand Up @@ -345,12 +344,15 @@ def __init__(self, address, options):
self.area_distance: float = None # how far this dev is from that area
self.area_rssi: float = None # rssi from closest scanner
self.area_scanner: str = None # name of closest scanner
self.zone: str = None # STATE_HOME or STATE_NOT_HOME
self.zone: str = STATE_UNAVAILABLE # STATE_HOME or STATE_NOT_HOME
self.manufacturer: str = None
self.connectable: bool = False
self.is_scanner: bool = False
self.entry_id: str = None # used for scanner devices
self.create_sensor: bool = False # Create/update a sensor for this device
self.create_sensor_done: bool = (
False # If we have requested the sensor be created
)
self.last_seen: float = (
0 # stamp from most recent scanner spotting. MONOTONIC_TIME
)
Expand Down Expand Up @@ -435,18 +437,13 @@ def __init__(
"""Initialize."""
# self.config_entry = entry
self.platforms = []
self.devices: dict[str, BermudaDevice] = {}
self.created_entities: set[BermudaEntity] = set()

self.area_reg = area_registry.async_get(hass)
self.config_entry = entry
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)

hass.services.async_register(
DOMAIN,
"dump_devices",
self.service_dump_devices,
vol.Schema({vol.Optional("addresses"): cv.string}),
SupportsResponse.ONLY,
)
# First time around we freshen the restored scanner info by
# forcing a scan of the captured info.
self._do_full_scanner_init = True

self.options = {}
if hasattr(entry, "options"):
Expand All @@ -464,11 +461,43 @@ def __init__(
):
self.options[key] = val

super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
self.devices: dict[str, BermudaDevice] = {}

self.area_reg = area_registry.async_get(hass)

# Restore the scanners saved in config entry data. We maintain
# a list of known scanners so we can
# restore the sensor states even if we don't have a full set of
# scanner receipts in the discovery data.
self.scanner_list = []
if hasattr(entry, "data"):
for address, saved in entry.data.get(CONFDATA_SCANNERS, {}).items():
scanner = self._get_or_create_device(address)
for key, value in saved.items():
setattr(scanner, key, value)
self.scanner_list.append(address)

hass.services.async_register(
DOMAIN,
"dump_devices",
self.service_dump_devices,
vol.Schema({vol.Optional("addresses"): cv.string}),
SupportsResponse.ONLY,
)

def sensor_created(self, address):
"""Allows sensor platform to report back that sensors have been set up"""
dev = self._get_device(address)
if dev is not None:
dev.create_sensor_done = True
else:
_LOGGER.warning("Very odd, we got sensor_created for non-tracked device")

def _get_device(self, address: str) -> BermudaDevice:
"""Search for a device entry based on mac address"""
mac = format_mac(address)
# format_mac tries to return a lower-cased, colon-separated mac address.
# failing that, it returns the original unaltered.
if mac in self.devices:
return self.devices[mac]
return None
Expand All @@ -478,7 +507,7 @@ def _get_or_create_device(self, address: str) -> BermudaDevice:
if device is None:
mac = format_mac(address)
self.devices[mac] = device = BermudaDevice(
address=address, options=self.options
address=mac, options=self.options
)
device.address = mac
device.unique_id = mac
Expand Down Expand Up @@ -569,7 +598,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._refresh_scanners(matched_scanners)
self._refresh_scanners(matched_scanners, self._do_full_scanner_init)
self._do_full_scanner_init = False
scanner_device = self._get_device(discovered.scanner.source)

if scanner_device is None:
Expand All @@ -586,23 +616,51 @@ async def _async_update_data(self):
# Replace the scanner entry on the current device
device.add_scanner(scanner_device, discovered)

# Update whether the device has been seen recently, for device_tracker:
if (
MONOTONIC_TIME()
- self.options.get(CONF_DEVTRACK_TIMEOUT, DEFAULT_DEVTRACK_TIMEOUT)
< device.last_seen
):
device.zone = STATE_HOME
else:
device.zone = STATE_NOT_HOME

if device.address.upper() in self.options.get(CONF_DEVICES, []):
# This is a device we track. Set it up:

device.create_sensor = True

# Update whether the device has been seen recently, for device_tracker:
if (
MONOTONIC_TIME()
- self.options.get(CONF_DEVTRACK_TIMEOUT, DEFAULT_DEVTRACK_TIMEOUT)
< device.last_seen
):
device.zone = STATE_HOME
else:
device.zone = STATE_NOT_HOME
# FIXME: If a tracked device isn't present at start-up, the sensor
# entities don't get created (via the add_entries call in sensor.py etc)
# and we don't have a mechanism to trigger that later. What you see in
# the gui is a "restored" entity, marked as no longer being provided by
# the integration. I think *here* might be the place to fix that.

self._refresh_areas_by_min_distance()

# We might need to freshen deliberately on first start this if no new scanners
# were discovered in the first scan update. This is likely if nothing has changed
# since the last time we booted.
if self._do_full_scanner_init:
self._refresh_scanners([], self._do_full_scanner_init)
self._do_full_scanner_init = False

# The devices are all updated now (and any new scanners seen have been added),
# 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))
if device is not None:
if not device.create_sensor_done:
_LOGGER.warning("Firing device_new for %s", device.name)
# self.hass.async_run_job(
async_dispatcher_send(
self.hass, SIGNAL_DEVICE_NEW, device.address, self.scanner_list
)
# )
# let the sensor platform do it intead: device.create_sensor_done = True

# end of async update

def dt_mono_to_datetime(self, stamp) -> datetime:
Expand Down Expand Up @@ -682,28 +740,67 @@ def _refresh_area_by_min_distance(self, device: BermudaDevice):
device.area_rssi = None
device.area_scanner = None

def _refresh_scanners(self, scanners: list[BluetoothScannerDevice]):
"""Refresh our local list of scanners (BLE Proxies)"""
def _refresh_scanners(
self, scanners: list[BluetoothScannerDevice], do_full_scan=False
):
"""Refresh our local (and saved) list of scanners (BLE Proxies)"""
addresses = set()
update_scannerlist = False

for scanner in scanners:
addresses.add(scanner.scanner.source.upper())
if len(addresses) > 0:

if do_full_scan or len(addresses) > 0:
# FIXME: Really? This can't possibly be a sensible nesting of loops.
# should probably look at the API. Anyway, we are checking any devices
# that have a "mac" or "bluetooth" connection,
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()
if found_address in addresses:
scandev = self._get_or_create_device(found_address)
if do_full_scan or found_address in addresses:
scandev = self._get_device(found_address)
if scandev is None:
# It's a new scanner, we will need to update our saved config.
_LOGGER.warning("New Scanner: %s", found_address)
update_scannerlist = True
scandev = self._get_or_create_device(found_address)
scandev_orig = scandev
scandev.area_id = dev_entry.area_id
scandev.entry_id = dev_entry.id
if dev_entry.name_by_user is not None:
scandev.name = dev_entry.name_by_user
else:
scandev.name = dev_entry.name
areas = self.area_reg.async_get_area(dev_entry.area_id)
if hasattr(areas, "name"):
scandev.area_name = areas.name
else:
_LOGGER.warning(
"No area name for while updating scanner %s",
scandev.name,
)
scandev.is_scanner = True
if scandev_orig != scandev:
# something changed, let's update the saved list.
update_scannerlist = True
if update_scannerlist:
# We need to update our saved list of scanners in config data.
self.scanner_list = []
scanners: dict[str, str] = {}
for device in self.devices.values():
if device.is_scanner:
scanners[device.address] = device.to_dict()
self.scanner_list.append(device.address)
_LOGGER.warning(
"Replacing config data scanners was %s now %s",
self.config_entry.data.get(CONFDATA_SCANNERS, {}),
scanners,
)
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, CONFDATA_SCANNERS: scanners},
)

async def service_dump_devices(self, call): # pylint: disable=unused-argument;
"""Return a dump of beacon advertisements by receiver"""
Expand Down
8 changes: 6 additions & 2 deletions custom_components/bermuda/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from .const import CONF_MAX_RADIUS
from .const import CONF_REF_POWER
from .const import DEFAULT_ATTENUATION
from .const import DEFAULT_DEVTRACK_TIMEOUT
from .const import DEFAULT_MAX_RADIUS
from .const import DEFAULT_REF_POWER
from .const import DOMAIN
from .const import NAME
Expand Down Expand Up @@ -126,11 +128,13 @@ async def async_step_globalopts(self, user_input=None):
data_schema = {
vol.Required(
CONF_MAX_RADIUS,
default=self.options.get(CONF_MAX_RADIUS, 3.0),
default=self.options.get(CONF_MAX_RADIUS, DEFAULT_MAX_RADIUS),
): vol.Coerce(float),
vol.Required(
CONF_DEVTRACK_TIMEOUT,
default=self.options.get(CONF_DEVTRACK_TIMEOUT, 30),
default=self.options.get(
CONF_DEVTRACK_TIMEOUT, DEFAULT_DEVTRACK_TIMEOUT
),
): vol.Coerce(int),
vol.Required(
CONF_ATTENUATION,
Expand Down
8 changes: 8 additions & 0 deletions custom_components/bermuda/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
# PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH]
PLATFORMS = [SENSOR, DEVICE_TRACKER]

# Signal names we are using:
SIGNAL_DEVICE_NEW = f"{DOMAIN}-device-new"

DOCS = {}

ADVERT_FRESHTIME = 2.5
Expand All @@ -39,6 +42,11 @@
10 # How many old timestamps, rssi, etc to keep for each device/scanner pairing.
)

# Config entry DATA entries

CONFDATA_SCANNERS = "scanners"
DOCS[CONFDATA_SCANNERS] = "Persisted set of known scanners (proxies)"

# Configuration and options

CONF_DEVICES = "configured_devices"
Expand Down
6 changes: 6 additions & 0 deletions custom_components/bermuda/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ class BermudaDeviceTracker(BermudaEntity, BaseTrackerEntity):
_attr_has_entity_name = True
_attr_name = None

@property
def unique_id(self):
""" "Uniquely identify this sensor so that it gets stored in the entity_registry,
and can be maintained / renamed etc by the user"""
return self._device.unique_id

@property
def extra_state_attributes(self) -> Mapping[str, str]:
"""Return extra state attributes for this device."""
Expand Down
Loading

0 comments on commit 71a7018

Please sign in to comment.