from abc import ABC, abstractmethod from collections import defaultdict from threading import RLock from typing import Any, Dict, Iterable, Optional, Tuple, Union from typing_extensions import override from platypush.entities.bluetooth import BluetoothDevice from ._model import MajorDeviceClass class BaseCache(ABC): """ Base cache class for Bluetooth devices and entities. """ _by_address: Dict[str, Any] """ Device cache by address. """ _by_name: Dict[str, Any] """ Device cache by name. """ def __init__(self): self._by_address = {} self._by_name = {} self._insert_locks = defaultdict(RLock) """ Locks for inserting devices into the cache. """ @property @abstractmethod def _address_field(self) -> str: """ Name of the field that contains the address of the device. """ @property @abstractmethod def _name_field(self) -> str: """ Name of the field that contains the name of the device. """ def get(self, device: str) -> Optional[Any]: """ Get a device by address or name. """ dev = self._by_address.get(device) if not dev: dev = self._by_name.get(device) return dev def add(self, device: Any) -> Any: """ Cache a device. """ with self._insert_locks[device.address]: addr = getattr(device, self._address_field) name = getattr(device, self._name_field) self._by_address[addr] = device if name: self._by_name[name] = device return device def keys(self) -> Iterable[str]: """ :return: All the cached device addresses. """ return list(self._by_address.keys()) def values(self) -> Iterable[Any]: """ :return: All the cached device entities. """ return list(self._by_address.values()) def items(self) -> Iterable[Tuple[str, Any]]: """ :return: All the cached items, as ``(address, device)`` tuples. """ return list(self._by_address.items()) def __contains__(self, device: str) -> bool: """ :return: ``True`` if the entry is cached, ``False`` otherwise. """ return self.get(device) is not None class EntityCache(BaseCache): """ Cache used to store scanned Bluetooth devices as :class:`BluetoothDevice`. """ _by_address: Dict[str, BluetoothDevice] _by_name: Dict[str, BluetoothDevice] @property @override def _address_field(self) -> str: return 'address' @property @override def _name_field(self) -> str: return 'name' @override def get(self, device: Union[str, BluetoothDevice]) -> Optional[BluetoothDevice]: dev_filter = device.address if isinstance(device, BluetoothDevice) else device return super().get(dev_filter) @override def add(self, device: BluetoothDevice) -> BluetoothDevice: with self._insert_locks[device.address]: existing_device = self.get(device) if existing_device: self._merge_properties(device, existing_device) self._merge_children(device, existing_device) device = existing_device return super().add(device) @override def values(self) -> Iterable[BluetoothDevice]: return super().values() @override def items(self) -> Iterable[Tuple[str, BluetoothDevice]]: return super().items() @override def __contains__(self, device: Union[str, BluetoothDevice]) -> bool: """ Override the default ``__contains__`` to support lookup by partial :class:`BluetoothDevice` objects. """ return super().__contains__(device) def _merge_properties( self, device: BluetoothDevice, existing_device: BluetoothDevice ): """ Coalesce the properties of the two device representations. """ # Coalesce the major device class if existing_device.major_device_class == MajorDeviceClass.UNKNOWN: existing_device.major_device_class = device.major_device_class # Coalesce the other device and service classes for attr in ('major_service_classes', 'minor_device_classes'): setattr( existing_device, attr, list( { *getattr(existing_device, attr, []), *getattr(device, attr, []), } ), ) # Coalesce mutually exclusive supports_* flags for attr in ('supports_ble', 'supports_legacy'): if not getattr(existing_device, attr, None): setattr(existing_device, attr, getattr(device, attr, None) or False) # Merge the connected property existing_device.connected = ( device.connected if device.connected is not None else existing_device.connected ) # Coalesce other manager-specific properties for attr in ('rssi', 'tx_power'): if getattr(existing_device, attr, None) is None: setattr(existing_device, attr, getattr(device, attr, None)) # Merge the data and meta dictionaries for attr in ('data', 'meta'): setattr( existing_device, attr, { **(getattr(existing_device, attr) or {}), **(getattr(device, attr) or {}), }, ) def _merge_children( self, device: BluetoothDevice, existing_device: BluetoothDevice ): """ Merge the device's children upon set without overwriting the existing ones. """ # Map of the existing children existing_children = { child.external_id: child for child in existing_device.children } # Map of the new children new_children = {child.id: child for child in device.children} # Merge the existing children with the new ones without overwriting them existing_children.update(new_children) existing_device.children = list(existing_children.values())