Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add raw ble advertisements support #439

Merged
merged 8 commits into from
Jun 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 21 additions & 1 deletion aioesphomeapi/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ message DeviceInfoResponse {

uint32 webserver_port = 10;

uint32 bluetooth_proxy_version = 11;
uint32 legacy_bluetooth_proxy_version = 11;
uint32 bluetooth_proxy_feature_flags = 15;

string manufacturer = 12;

Expand Down Expand Up @@ -1158,6 +1159,8 @@ message MediaPlayerCommandRequest {
message SubscribeBluetoothLEAdvertisementsRequest {
option (id) = 66;
option (source) = SOURCE_CLIENT;

int32 flags = 1;
}

message BluetoothServiceData {
Expand All @@ -1181,6 +1184,23 @@ message BluetoothLEAdvertisementResponse {
uint32 address_type = 7;
}

message BluetoothLERawAdvertisement {
uint64 address = 1;
sint32 rssi = 2;
uint32 address_type = 3;

bytes data = 4;
}

message BluetoothLERawAdvertisementsResponse {
option (id) = 93;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (no_delay) = true;

repeated BluetoothLERawAdvertisement advertisements = 1;
}

enum BluetoothDeviceRequestType {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0;
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1;
Expand Down
478 changes: 250 additions & 228 deletions aioesphomeapi/api_pb2.py

Large diffs are not rendered by default.

46 changes: 40 additions & 6 deletions aioesphomeapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
BluetoothGATTWriteRequest,
BluetoothGATTWriteResponse,
BluetoothLEAdvertisementResponse,
BluetoothLERawAdvertisementsResponse,
ButtonCommandRequest,
CameraImageRequest,
CameraImageResponse,
Expand Down Expand Up @@ -125,6 +126,10 @@
BluetoothGATTRead,
BluetoothGATTServices,
BluetoothLEAdvertisement,
BluetoothLERawAdvertisement,
BluetoothLERawAdvertisements,
BluetoothProxyFeature,
BluetoothProxySubscriptionFlag,
ButtonInfo,
CameraInfo,
CameraState,
Expand Down Expand Up @@ -477,7 +482,36 @@ def on_msg(msg: BluetoothLEAdvertisementResponse) -> None:

assert self._connection is not None
self._connection.send_message_callback_response(
SubscribeBluetoothLEAdvertisementsRequest(), on_msg, msg_types
SubscribeBluetoothLEAdvertisementsRequest(flags=0),
on_msg,
msg_types,
)

def unsub() -> None:
if self._connection is not None:
self._connection.remove_message_callback(on_msg, msg_types)
self._connection.send_message(
UnsubscribeBluetoothLEAdvertisementsRequest()
)

return unsub

async def subscribe_bluetooth_le_raw_advertisements(
self, on_advertisements: Callable[[List[BluetoothLERawAdvertisement]], None]
) -> Callable[[], None]:
self._check_authenticated()
msg_types = (BluetoothLERawAdvertisementsResponse,)

def on_msg(msg: BluetoothLERawAdvertisementsResponse) -> None:
on_advertisements(BluetoothLERawAdvertisements.from_pb(msg).advertisements) # type: ignore[misc]

assert self._connection is not None
self._connection.send_message_callback_response(
SubscribeBluetoothLEAdvertisementsRequest(
flags=BluetoothProxySubscriptionFlag.RAW_ADVERTISEMENTS
),
on_msg,
msg_types,
)

def unsub() -> None:
Expand Down Expand Up @@ -516,7 +550,7 @@ async def bluetooth_device_connect( # pylint: disable=too-many-locals
on_bluetooth_connection_state: Callable[[bool, int, int], None],
timeout: float = DEFAULT_BLE_TIMEOUT,
disconnect_timeout: float = DEFAULT_BLE_DISCONNECT_TIMEOUT,
version: int = 1,
feature_flags: int = 0,
has_cache: bool = False,
address_type: Optional[int] = None,
) -> Callable[[], None]:
Expand All @@ -536,15 +570,15 @@ def on_msg(msg: BluetoothDeviceConnectionResponse) -> None:

assert self._connection is not None
if has_cache:
# Version 3 with cache: requestor has services and mtu cached
# REMOTE_CACHING feature with cache: requestor has services and mtu cached
_LOGGER.debug("%s: Using connection version 3 with cache", address)
request_type = BluetoothDeviceRequestType.CONNECT_V3_WITH_CACHE
elif version >= 3:
# Version 3 without cache: esp will wipe the service list after sending to save memory
elif feature_flags & BluetoothProxyFeature.REMOTE_CACHING:
# REMOTE_CACHING feature without cache: esp will wipe the service list after sending to save memory
_LOGGER.debug("%s: Using connection version 3 without cache", address)
request_type = BluetoothDeviceRequestType.CONNECT_V3_WITHOUT_CACHE
else:
# Older than v3 without cache: esp will hold the service list in memory for the duration
# Device doesnt support REMOTE_CACHING feature: esp will hold the service list in memory for the duration
# of the connection. This can crash the esp if the service list is too large.
_LOGGER.debug("%s: Using connection version 1", address)
request_type = BluetoothDeviceRequestType.CONNECT
Expand Down
2 changes: 1 addition & 1 deletion aioesphomeapi/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ async def _connect_hello(self) -> None:
hello = HelloRequest()
hello.client_info = self._params.client_info
hello.api_version_major = 1
hello.api_version_minor = 7
hello.api_version_minor = 9
try:
resp = await self.send_message_await_response(hello, HelloResponse)
except TimeoutAPIError as err:
Expand Down
2 changes: 2 additions & 0 deletions aioesphomeapi/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
BluetoothGATTWriteRequest,
BluetoothGATTWriteResponse,
BluetoothLEAdvertisementResponse,
BluetoothLERawAdvertisementsResponse,
ButtonCommandRequest,
CameraImageRequest,
CameraImageResponse,
Expand Down Expand Up @@ -318,4 +319,5 @@ def __init__(self, error: BluetoothGATTError) -> None:
90: VoiceAssistantRequest,
91: VoiceAssistantResponse,
92: VoiceAssistantEventResponse,
93: BluetoothLERawAdvertisementsResponse,
}
60 changes: 59 additions & 1 deletion aioesphomeapi/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
if TYPE_CHECKING:
from .api_pb2 import ( # type: ignore
BluetoothLEAdvertisementResponse,
BluetoothLERawAdvertisementsResponse,
HomeassistantServiceMap,
)

Expand Down Expand Up @@ -110,6 +111,19 @@ class APIVersion(APIModelBase):
minor: int = 0


class BluetoothProxyFeature(enum.IntFlag):
PASSIVE_SCAN = 1 << 0
ACTIVE_CONNECTIONS = 1 << 1
REMOTE_CACHING = 1 << 2
PAIRING = 1 << 3
CACHE_CLEARING = 1 << 4
RAW_ADVERTISEMENTS = 1 << 5


class BluetoothProxySubscriptionFlag(enum.IntFlag):
RAW_ADVERTISEMENTS = 1 << 0


@dataclass(frozen=True)
class DeviceInfo(APIModelBase):
uses_password: bool = False
Expand All @@ -124,8 +138,25 @@ class DeviceInfo(APIModelBase):
project_name: str = ""
project_version: str = ""
webserver_port: int = 0
bluetooth_proxy_version: int = 0
voice_assistant_version: int = 0
legacy_bluetooth_proxy_version: int = 0
bluetooth_proxy_feature_flags: int = 0

def bluetooth_proxy_feature_flags_compat(self, api_version: APIVersion) -> int:
if api_version < APIVersion(1, 9):
flags: int = 0
if self.legacy_bluetooth_proxy_version >= 1:
flags |= BluetoothProxyFeature.PASSIVE_SCAN
if self.legacy_bluetooth_proxy_version >= 2:
flags |= BluetoothProxyFeature.ACTIVE_CONNECTIONS
if self.legacy_bluetooth_proxy_version >= 3:
flags |= BluetoothProxyFeature.REMOTE_CACHING
if self.legacy_bluetooth_proxy_version >= 4:
flags |= BluetoothProxyFeature.PAIRING
if self.legacy_bluetooth_proxy_version >= 5:
flags |= BluetoothProxyFeature.CACHE_CLEARING
return flags
return self.bluetooth_proxy_feature_flags


class EntityCategory(APIIntEnum):
Expand Down Expand Up @@ -856,6 +887,33 @@ def from_pb( # type: ignore[misc]
)


@_dataclass_decorator
class BluetoothLERawAdvertisement:
address: int
rssi: int
address_type: int
data: bytes = field(default_factory=bytes)


@_dataclass_decorator
class BluetoothLERawAdvertisements:
advertisements: List[BluetoothLERawAdvertisement]

@classmethod
def from_pb( # type: ignore[misc]
cls: "BluetoothLERawAdvertisements",
data: "BluetoothLERawAdvertisementsResponse",
) -> "BluetoothLERawAdvertisements":
return cls( # type: ignore[operator, no-any-return]
advertisements=[
BluetoothLERawAdvertisement( # type: ignore[call-arg]
adv.address, adv.rssi, adv.address_type, adv.data
)
for adv in data.advertisements
]
)


@dataclass(frozen=True)
class BluetoothDeviceConnection(APIModelBase):
address: int = 0
Expand Down