Skip to content

Commit 1aa9173

Browse files
committed
feat: significant restructuring
- more efficient processing of updates - better handling of proxies being added/removed - improvements to naming when first adding / browsing in select devices ## User-visible / features - feat: Shorten/redact scanner macs in configure status display - distance_to... sensors are now created/removed in realtime as scanners come and go. - fix: startup is now faster due to better asyncio usage ## Breaking changes - bermuda.dump_devices service/action format has changed! If you are using this please test as many items have been restructured - scanners dict renamed to "adverts", now keyed as device/scanner tuples ## Code changes - clarification on processing order for metadevices - no longer save/restore scanner list in config, now dynamically CRUD'd - implement scanner list as properties to centralise changes - add customisations to manufacturer lookups - centralised name generation - added Bluetooth Company Identifiers yaml - switch to aiofiles for yaml imports, and place in separate task - remove extra calls to async_update_data - big refactoring of async_update_data to improve separation of concerns and decrease complexity - reworking of prune_devices to avoid missing devices or creating churn - add time check to redaction_list_update - refactor device creation and updating to improve SOC and complexity - change from MONOTONIC_TIME to monotonic_time_coarse - broaden BDADDR_TYPEs and add METADEVICE_DEVICETYPES - make BermudaDeviceScanner hashable - make BermudaDevice hashable - use platform constants now in core. - change to_dict methods (and others) to avoid matching attributes by string. Improved data presentation. - tidy up remove_config_entry_device - use async_schedule_reload instead of immediate Signed-off-by: Ashley Gittins <[email protected]>
1 parent 47549ae commit 1aa9173

File tree

13 files changed

+12553
-807
lines changed

13 files changed

+12553
-807
lines changed

custom_components/bermuda/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,16 @@ def update_unique_id(entity_entry):
102102
async def async_remove_config_entry_device(
103103
hass: HomeAssistant, config_entry: BermudaConfigEntry, device_entry: DeviceEntry
104104
) -> bool:
105-
"""Remove a config entry from a device."""
105+
"""Implements user-deletion of devices from device registry."""
106106
coordinator: BermudaDataUpdateCoordinator = config_entry.runtime_data.coordinator
107107
address = None
108-
for ident in device_entry.identifiers:
108+
for domain, ident in device_entry.identifiers:
109109
try:
110-
if ident[0] == DOMAIN:
110+
if domain == DOMAIN:
111111
# the identifier should be the base device address, and
112112
# may have "_range" or some other per-sensor suffix.
113113
# The address might be a mac address, IRK or iBeacon uuid
114-
address = ident[1].split("_")[0]
114+
address = ident.split("_")[0]
115115
except KeyError:
116116
pass
117117
if address is not None:
@@ -139,4 +139,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: BermudaConfigEntry) ->
139139

140140
async def async_reload_entry(hass: HomeAssistant, entry: BermudaConfigEntry) -> None:
141141
"""Reload config entry."""
142-
await hass.config_entries.async_reload(entry.entry_id)
142+
hass.config_entries.async_schedule_reload(entry.entry_id)

custom_components/bermuda/bermuda_device.py

Lines changed: 276 additions & 61 deletions
Large diffs are not rendered by default.

custom_components/bermuda/bermuda_device_scanner.py

Lines changed: 139 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,9 @@
1414

1515
from __future__ import annotations
1616

17-
from typing import TYPE_CHECKING, cast
17+
from typing import TYPE_CHECKING, Final
1818

19-
from homeassistant.components.bluetooth import (
20-
MONOTONIC_TIME,
21-
BluetoothScannerDevice,
22-
)
19+
from bluetooth_data_tools import monotonic_time_coarse
2320

2421
from .const import (
2522
_LOGGER,
@@ -34,9 +31,11 @@
3431
)
3532

3633
# from .const import _LOGGER_SPAM_LESS
37-
from .util import rssi_to_metres
34+
from .util import clean_charbuf, rssi_to_metres
3835

3936
if TYPE_CHECKING:
37+
from bleak.backends.scanner import AdvertisementData
38+
4039
from .bermuda_device import BermudaDevice
4140

4241
# The if instead of min/max triggers PLR1730, but when
@@ -58,35 +57,35 @@ class BermudaDeviceScanner(dict):
5857
This is created (and updated) by the receipt of an advertisement, which represents
5958
a BermudaDevice hearing an advert from another BermudaDevice, if that makes sense!
6059
61-
A BermudaDevice's "scanners" property will contain one of these for each
60+
A BermudaDevice's "adverts" property will contain one of these for each
6261
scanner that has "seen" it.
6362
6463
"""
6564

65+
def __hash__(self) -> int:
66+
"""The device-mac / scanner mac uniquely identifies a received advertisement pair."""
67+
return hash((self.device_address, self.scanner_address))
68+
6669
def __init__(
6770
self,
6871
parent_device: BermudaDevice, # The device being tracked
69-
scandata: BluetoothScannerDevice, # The advertisement info from the device, received by the scanner
72+
advertisementdata: AdvertisementData, # The advertisement info from the device, received by the scanner
7073
options,
7174
scanner_device: BermudaDevice, # The scanner device that "saw" it.
7275
) -> None:
7376
# I am declaring these just to control their order in the dump,
7477
# which is a bit silly, I suspect.
7578
self.name: str = scanner_device.name # or scandata.scanner.name
7679
self.scanner_device = scanner_device # links to the source device
77-
self.adapter: str = scandata.scanner.adapter # a helpful name, like hci0 or prox-test
78-
self.scanner_address = scanner_device.address
79-
self.source: str = scandata.scanner.source
80+
self.scanner_address: Final[str] = scanner_device.address
8081
self.area_id: str | None = scanner_device.area_id
8182
self.area_name: str | None = scanner_device.area_name
8283
self._device = parent_device
83-
self.device_address = parent_device.address
84+
self.device_address: Final[str] = parent_device.address
8485
self.options = options
8586
self.stamp: float = 0
8687
# Only remote scanners log timestamps, local usb adaptors do not.
87-
self.scanner_sends_stamps = hasattr(scandata.scanner, "discovered_device_timestamps") or hasattr(
88-
scandata.scanner, "_discovered_device_timestamps"
89-
)
88+
self.scanner_sends_stamps = scanner_device.is_remote_scanner
9089
self.new_stamp: float | None = None # Set when a new advert is loaded from update
9190
self.rssi: float | None = None
9291
self.tx_power: float | None = None
@@ -105,17 +104,15 @@ def __init__(
105104
self.conf_attenuation = self.options.get(CONF_ATTENUATION)
106105
self.conf_max_velocity = self.options.get(CONF_MAX_VELOCITY)
107106
self.conf_smoothing_samples = self.options.get(CONF_SMOOTHING_SAMPLES)
108-
self.adverts: dict[str, list] = {
109-
"manufacturer_data": [],
110-
"service_data": [],
111-
"service_uuids": [],
112-
"platform_data": [],
113-
}
107+
self.local_name: list[tuple[str, bytes]] = []
108+
self.manufacturer_data: list[dict[int, bytes]] = []
109+
self.service_data: list[dict[str, bytes]] = []
110+
self.service_uuids: list[str] = []
114111

115112
# Just pass the rest on to update...
116-
self.update_advertisement(scandata)
113+
self.update_advertisement(advertisementdata)
117114

118-
def update_advertisement(self, scandata: BluetoothScannerDevice):
115+
def update_advertisement(self, advertisementdata: AdvertisementData):
119116
"""
120117
Update gets called every time we see a new packet or
121118
every time we do a polled update.
@@ -124,57 +121,55 @@ def update_advertisement(self, scandata: BluetoothScannerDevice):
124121
device+scanner combination. This method only gets called when a given scanner
125122
claims to have data.
126123
"""
127-
# In case the scanner has changed it's details since startup:
128-
# FIXME: This should probably be a separate function that the refresh_scanners
129-
# calls if necessary, rather than re-doing it every cycle.
130-
scanner = scandata.scanner
131-
# self.name = scanner.name
132-
# self.area_id = self.scanner_device.area_id
133-
# self.area_name = self.scanner_device.area_name
124+
#
125+
# We might get called without there being a new advert to process, so
126+
# exit quickly if that's the case (ideally we will catch it earlier in future)
127+
#
128+
scanner = self.scanner_device
134129
new_stamp: float | None = None
135130

136131
if self.scanner_sends_stamps:
137-
# Found a remote scanner which has timestamp history...
138-
# Check can be removed when we require 2025.4+
139-
if hasattr(scanner, "discovered_device_timestamps"):
140-
stamps = scanner.discovered_device_timestamps # type: ignore
141-
else:
142-
stamps = scanner._discovered_device_timestamps # type: ignore #noqa
132+
new_stamp = scanner.async_as_scanner_get_stamp(self.device_address)
143133

144-
# In this dict all MAC address keys are upper-cased
145-
uppermac = self.device_address.upper()
146-
if uppermac in stamps:
147-
if self.stamp is None or (stamps[uppermac] is not None and stamps[uppermac] > self.stamp):
148-
new_stamp = stamps[uppermac]
149-
else:
150-
# We have no updated advert in this run.
151-
new_stamp = None
152-
self.stale_update_count += 1
153-
else:
154-
# This shouldn't happen, as we shouldn't have got a record
155-
# of this scanner if it hadn't seen this device.
156-
_LOGGER.error(
157-
"Scanner %s has no stamp for %s - very odd",
158-
scanner.source,
159-
self.device_address,
160-
)
161-
new_stamp = None
162-
elif self.rssi != scandata.advertisement.rssi:
134+
if new_stamp is None:
135+
self.stale_update_count += 1
136+
_LOGGER.warning("Advert from %s for %s lacks stamp, unexpected.", scanner.name, self._device.name)
137+
return
138+
139+
if self.stamp > new_stamp:
140+
# The existing stamp is NEWER, bail but complain on the way.
141+
self.stale_update_count += 1
142+
_LOGGER.warning("Advert from %s for %s is OLDER than last recorded", scanner.name, self._device.name)
143+
return
144+
145+
if self.stamp == new_stamp:
146+
# We've seen this stamp before. Bail.
147+
self.stale_update_count += 1
148+
return
149+
150+
elif self.rssi != advertisementdata.rssi:
163151
# If the rssi has changed from last time, consider it "new". Since this scanner does
164152
# not send stamps, this is probably a USB bluetooth adaptor.
165-
new_stamp = MONOTONIC_TIME()
153+
new_stamp = monotonic_time_coarse() - 3.0 # age usb adaptors slightly, since they are not "fresh"
166154
else:
167-
new_stamp = None
155+
# USB Adaptor has nothing new for us, bail.
156+
return
168157

169158
# Update our parent scanner's last_seen if we have a new stamp.
170-
if new_stamp is not None and new_stamp > self.scanner_device.last_seen:
159+
if new_stamp > self.scanner_device.last_seen + 0.01: # some slight warp seems common.
160+
_LOGGER.info(
161+
"Advert from %s for %s is %.6fs NEWER than scanner's last_seen, odd",
162+
self.scanner_device.name,
163+
self._device.name,
164+
new_stamp - self.scanner_device.last_seen,
165+
)
171166
self.scanner_device.last_seen = new_stamp
172167

173168
if len(self.hist_stamp) == 0 or new_stamp is not None:
174169
# this is the first entry or a new one, bring in the new reading
175170
# and calculate the distance.
176171

177-
self.rssi = scandata.advertisement.rssi
172+
self.rssi = advertisementdata.rssi
178173
self.hist_rssi.insert(0, self.rssi)
179174

180175
self._update_raw_distance(reading_is_new=True)
@@ -210,21 +205,51 @@ def update_advertisement(self, scandata: BluetoothScannerDevice):
210205
# self.parent_device_address,
211206
# scandata.advertisement.tx_power,
212207
# )
213-
self.tx_power = scandata.advertisement.tx_power
214-
215-
# Track each advertisement element as or if they change.
216-
for key, data in self.adverts.items():
217-
if key == "platform_data":
218-
# This duplicates the other keys and doesn't encode to JSON without
219-
# extra work.
220-
continue
221-
new_data = getattr(scandata.advertisement, key, {})
222-
if len(new_data) > 0:
223-
if len(data) == 0 or data[0] != new_data:
224-
data.insert(0, new_data)
225-
# trim to keep size in check
226-
del data[HIST_KEEP_COUNT:]
227-
208+
self.tx_power = advertisementdata.tx_power
209+
210+
# Store each of the extra advertisement fields in historical lists.
211+
# Track if we should tell the parent device to update its name
212+
_want_name_update = False
213+
if advertisementdata.local_name is not None:
214+
# It's not uncommon to find BT devices with nonascii junk in their
215+
# local_name (like nulls, \n, etc). Store a cleaned version as str
216+
# and the original as bytes.
217+
# Devices may also advert multiple names over time.
218+
nametuplet = (clean_charbuf(advertisementdata.local_name), advertisementdata.local_name.encode())
219+
if len(self.local_name) == 0 or self.local_name[0] != nametuplet:
220+
self.local_name.insert(0, nametuplet)
221+
del self.local_name[HIST_KEEP_COUNT:]
222+
# Lets see if we should pass the new name up to the parent device.
223+
if self._device.name_bt_local_name is None or len(self._device.name_bt_local_name) < len(nametuplet[0]):
224+
self._device.name_bt_local_name = nametuplet[0]
225+
_want_name_update = True
226+
227+
if len(self.manufacturer_data) == 0 or self.manufacturer_data[0] != advertisementdata.manufacturer_data:
228+
self.manufacturer_data.insert(0, advertisementdata.manufacturer_data)
229+
230+
if advertisementdata.manufacturer_data not in self.manufacturer_data[1:]:
231+
# We just stored a manu_data that wasn't in the previous history,
232+
# so tell our parent device about it.
233+
self._device.process_manufacturer_data(self)
234+
_want_name_update = True
235+
del self.manufacturer_data[HIST_KEEP_COUNT:]
236+
237+
if len(self.service_data) == 0 or self.service_data[0] != advertisementdata.service_data:
238+
self.service_data.insert(0, advertisementdata.service_data)
239+
if advertisementdata.service_data not in self.manufacturer_data[1:]:
240+
_want_name_update = True
241+
del self.service_data[HIST_KEEP_COUNT:]
242+
243+
for service_uuid in advertisementdata.service_uuids:
244+
if service_uuid not in self.service_uuids:
245+
self.service_uuids.insert(0, service_uuid)
246+
_want_name_update = True
247+
del self.service_uuids[HIST_KEEP_COUNT:]
248+
249+
if _want_name_update:
250+
self._device.make_name()
251+
252+
# Finally, save the new advert timestamp.
228253
self.new_stamp = new_stamp
229254

230255
def _update_raw_distance(self, reading_is_new=True) -> float:
@@ -346,7 +371,7 @@ def calculate_data(self):
346371
self.hist_distance_by_interval.clear()
347372
self.hist_distance_by_interval.append(self.rssi_distance_raw)
348373

349-
elif new_stamp is None and (self.stamp is None or self.stamp < MONOTONIC_TIME() - DISTANCE_TIMEOUT):
374+
elif new_stamp is None and (self.stamp is None or self.stamp < monotonic_time_coarse() - DISTANCE_TIMEOUT):
350375
# DEVICE IS AWAY!
351376
# Last distance reading is stale, mark device distance as unknown.
352377
self.rssi_distance = None
@@ -459,27 +484,51 @@ def calculate_data(self):
459484

460485
def to_dict(self):
461486
"""Convert class to serialisable dict for dump_devices."""
487+
# using "is" comparisons instead of string matching means
488+
# linting and typing can catch errors.
462489
out = {}
463490
for var, val in vars(self).items():
464-
if var in ["options", "parent_device", "scanner_device"]:
491+
if val in [self.options]:
465492
# skip certain vars that we don't want in the dump output.
466493
continue
467-
if var == "adverts":
468-
adout = {}
469-
for adtype, adarray in val.items():
470-
out_adarray = []
471-
for ad_data in adarray:
472-
if adtype in ["manufacturer_data", "service_data"]:
473-
for ad_key, ad_value in ad_data.items():
474-
out_adarray.append({ad_key: cast("bytes", ad_value).hex()})
475-
else:
476-
out_adarray.append(ad_data)
477-
adout[adtype] = out_adarray
478-
out[var] = adout
494+
if val in [self.options, self._device, self.scanner_device]:
495+
# objects we might want to represent but not fully iterate etc.
496+
out[var] = val.__repr__()
497+
continue
498+
if val is self.local_name:
499+
out[var] = {}
500+
for namestr, namebytes in self.local_name:
501+
out[var][namestr] = namebytes.hex()
502+
continue
503+
if val is self.manufacturer_data:
504+
out[var] = {}
505+
for manrow in self.manufacturer_data:
506+
for manid, manbytes in manrow.items():
507+
out[var][manid] = manbytes.hex()
508+
continue
509+
if val is self.service_data:
510+
out[var] = {}
511+
for svrow in self.service_data:
512+
for svid, svbytes in svrow.items():
513+
out[var][svid] = svbytes.hex()
514+
continue
515+
if isinstance(val, str | int):
516+
out[var] = val
517+
continue
518+
if isinstance(val, float):
519+
out[var] = round(val, 4)
520+
continue
521+
if isinstance(val, list):
522+
out[var] = []
523+
for row in val:
524+
if isinstance(row, float):
525+
out[var].append(round(row, 4))
526+
else:
527+
out[var].append(row)
479528
continue
480-
out[var] = val
529+
out[var] = val.__repr__()
481530
return out
482531

483532
def __repr__(self) -> str:
484533
"""Help debugging by giving it a clear name instead of empty dict."""
485-
return f"{self.device_address}__{self.scanner_address}"
534+
return f"{self.device_address}__{self.scanner_device.name}"

0 commit comments

Comments
 (0)