Add Bluetooth GATT calls (#259)

This commit is contained in:
Jesse Hills 2022-09-29 05:50:37 +13:00 committed by GitHub
parent 0e1ae31667
commit 6a82766553
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 1596 additions and 73 deletions

View File

@ -27,7 +27,6 @@ service APIConnection {
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
rpc subscribe_homeassistant_services (SubscribeHomeassistantServicesRequest) returns (void) {} rpc subscribe_homeassistant_services (SubscribeHomeassistantServicesRequest) returns (void) {}
rpc subscribe_home_assistant_states (SubscribeHomeAssistantStatesRequest) returns (void) {} rpc subscribe_home_assistant_states (SubscribeHomeAssistantStatesRequest) returns (void) {}
rpc subscribe_bluetooth_le_advertisements (SubscribeBluetoothLEAdvertisementsRequest) returns (void) {}
rpc get_time (GetTimeRequest) returns (GetTimeResponse) { rpc get_time (GetTimeRequest) returns (GetTimeResponse) {
option (needs_authentication) = false; option (needs_authentication) = false;
} }
@ -45,6 +44,15 @@ service APIConnection {
rpc button_command (ButtonCommandRequest) returns (void) {} rpc button_command (ButtonCommandRequest) returns (void) {}
rpc lock_command (LockCommandRequest) returns (void) {} rpc lock_command (LockCommandRequest) returns (void) {}
rpc media_player_command (MediaPlayerCommandRequest) returns (void) {} rpc media_player_command (MediaPlayerCommandRequest) returns (void) {}
rpc subscribe_bluetooth_le_advertisements (SubscribeBluetoothLEAdvertisementsRequest) returns (void) {}
rpc bluetooth_device_request(BluetoothDeviceRequest) returns (void) {}
rpc bluetooth_gatt_get_services(BluetoothGATTGetServicesRequest) returns (void) {}
rpc bluetooth_gatt_read(BluetoothGATTReadRequest) returns (void) {}
rpc bluetooth_gatt_write(BluetoothGATTWriteRequest) returns (void) {}
rpc bluetooth_gatt_read_descriptor(BluetoothGATTReadDescriptorRequest) returns (void) {}
rpc bluetooth_gatt_write_descriptor(BluetoothGATTWriteDescriptorRequest) returns (void) {}
rpc bluetooth_gatt_notify(BluetoothGATTNotifyRequest) returns (void) {}
} }
@ -1138,7 +1146,7 @@ message SubscribeBluetoothLEAdvertisementsRequest {
message BluetoothServiceData { message BluetoothServiceData {
string uuid = 1; string uuid = 1;
repeated uint32 data = 2 [packed=false]; repeated uint32 data = 2 [packed=true];
} }
message BluetoothLEAdvertisementResponse { message BluetoothLEAdvertisementResponse {
option (id) = 67; option (id) = 67;
@ -1154,3 +1162,163 @@ message BluetoothLEAdvertisementResponse {
repeated BluetoothServiceData service_data = 5; repeated BluetoothServiceData service_data = 5;
repeated BluetoothServiceData manufacturer_data = 6; repeated BluetoothServiceData manufacturer_data = 6;
} }
enum BluetoothDeviceRequestType {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0;
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1;
BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR = 2;
BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR = 3;
}
message BluetoothDeviceRequest {
option (id) = 68;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
BluetoothDeviceRequestType request_type = 2;
}
message BluetoothDeviceConnectionResponse {
option (id) = 69;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
bool connected = 2;
uint32 mtu = 3;
int32 error = 4;
}
message BluetoothGATTGetServicesRequest {
option (id) = 70;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
}
message BluetoothGATTDescriptor {
string uuid = 1;
uint32 handle = 2;
string description = 3;
}
message BluetoothGATTCharacteristic {
string uuid = 1;
uint32 handle = 2;
uint32 properties = 3;
repeated BluetoothGATTDescriptor descriptors = 4;
}
message BluetoothGATTService {
string uuid = 1;
uint32 handle = 2;
repeated BluetoothGATTCharacteristic characteristics = 3;
}
message BluetoothGATTGetServicesResponse {
option (id) = 71;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
repeated BluetoothGATTService services = 2;
}
message BluetoothGATTGetServicesDoneResponse {
option (id) = 72;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
}
message BluetoothGATTReadRequest {
option (id) = 73;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
uint32 handle = 2;
}
message BluetoothGATTReadResponse {
option (id) = 74;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
uint32 handle = 2;
bytes data = 3;
}
message BluetoothGATTWriteRequest {
option (id) = 75;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
uint32 handle = 2;
bool response = 3;
bytes data = 4;
}
message BluetoothGATTReadDescriptorRequest {
option (id) = 76;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
uint32 handle = 2;
}
message BluetoothGATTWriteDescriptorRequest {
option (id) = 77;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
uint32 handle = 2;
bytes data = 3;
}
message BluetoothGATTNotifyRequest {
option (id) = 78;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
uint32 handle = 2;
bool enable = 3;
}
message BluetoothGATTNotifyDataResponse {
option (id) = 79;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint64 address = 1;
uint32 handle = 2;
bytes data = 3;
}
message SubscribeBluetoothConnectionsFreeRequest {
option (id) = 80;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
}
message BluetoothConnectionsFreeResponse {
option (id) = 81;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
uint32 free = 1;
uint32 limit = 2;
}

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,11 @@
# pylint: disable=too-many-lines
import asyncio
import logging import logging
from typing import ( from typing import (
Any, Any,
Awaitable, Awaitable,
Callable, Callable,
Coroutine,
Dict, Dict,
List, List,
Optional, Optional,
@ -12,10 +15,24 @@ from typing import (
cast, cast,
) )
import async_timeout
from google.protobuf import message from google.protobuf import message
from .api_pb2 import ( # type: ignore from .api_pb2 import ( # type: ignore
BinarySensorStateResponse, BinarySensorStateResponse,
BluetoothConnectionsFreeResponse,
BluetoothDeviceConnectionResponse,
BluetoothDeviceRequest,
BluetoothGATTGetServicesDoneResponse,
BluetoothGATTGetServicesRequest,
BluetoothGATTGetServicesResponse,
BluetoothGATTNotifyDataResponse,
BluetoothGATTNotifyRequest,
BluetoothGATTReadDescriptorRequest,
BluetoothGATTReadRequest,
BluetoothGATTReadResponse,
BluetoothGATTWriteDescriptorRequest,
BluetoothGATTWriteRequest,
BluetoothLEAdvertisementResponse, BluetoothLEAdvertisementResponse,
ButtonCommandRequest, ButtonCommandRequest,
CameraImageRequest, CameraImageRequest,
@ -63,6 +80,7 @@ from .api_pb2 import ( # type: ignore
SensorStateResponse, SensorStateResponse,
SirenCommandRequest, SirenCommandRequest,
SirenStateResponse, SirenStateResponse,
SubscribeBluetoothConnectionsFreeRequest,
SubscribeBluetoothLEAdvertisementsRequest, SubscribeBluetoothLEAdvertisementsRequest,
SubscribeHomeassistantServicesRequest, SubscribeHomeassistantServicesRequest,
SubscribeHomeAssistantStateResponse, SubscribeHomeAssistantStateResponse,
@ -75,12 +93,17 @@ from .api_pb2 import ( # type: ignore
TextSensorStateResponse, TextSensorStateResponse,
) )
from .connection import APIConnection, ConnectionParams from .connection import APIConnection, ConnectionParams
from .core import APIConnectionError from .core import APIConnectionError, TimeoutAPIError
from .host_resolver import ZeroconfInstanceType from .host_resolver import ZeroconfInstanceType
from .model import ( from .model import (
APIVersion, APIVersion,
BinarySensorInfo, BinarySensorInfo,
BinarySensorState, BinarySensorState,
BluetoothConnectionsFree,
BluetoothDeviceConnection,
BluetoothDeviceRequestType,
BluetoothGATTRead,
BluetoothGATTServices,
BluetoothLEAdvertisement, BluetoothLEAdvertisement,
ButtonInfo, ButtonInfo,
CameraInfo, CameraInfo,
@ -384,7 +407,7 @@ class APIClient:
async def subscribe_bluetooth_le_advertisements( async def subscribe_bluetooth_le_advertisements(
self, on_bluetooth_le_advertisement: Callable[[BluetoothLEAdvertisement], None] self, on_bluetooth_le_advertisement: Callable[[BluetoothLEAdvertisement], None]
) -> None: ) -> Callable[[], None]:
self._check_authenticated() self._check_authenticated()
def on_msg(msg: message.Message) -> None: def on_msg(msg: message.Message) -> None:
@ -396,6 +419,227 @@ class APIClient:
SubscribeBluetoothLEAdvertisementsRequest(), on_msg SubscribeBluetoothLEAdvertisementsRequest(), on_msg
) )
def unsub() -> None:
assert self._connection is not None
self._connection.remove_message_callback(on_msg)
return unsub
async def subscribe_bluetooth_connections_free(
self, on_bluetooth_connections_free_update: Callable[[int, int], None]
) -> Callable[[], None]:
self._check_authenticated()
def on_msg(msg: message.Message) -> None:
if isinstance(msg, BluetoothConnectionsFreeResponse):
resp = BluetoothConnectionsFree.from_pb(msg)
on_bluetooth_connections_free_update(resp.free, resp.limit)
assert self._connection is not None
await self._connection.send_message_callback_response(
SubscribeBluetoothConnectionsFreeRequest(), on_msg
)
def unsub() -> None:
assert self._connection is not None
self._connection.remove_message_callback(on_msg)
return unsub
async def bluetooth_device_connect(
self,
address: int,
on_bluetooth_connection_state: Callable[[bool, int, int], None],
timeout: float = 10.0,
) -> Callable[[], None]:
self._check_authenticated()
event = asyncio.Event()
def on_msg(msg: message.Message) -> None:
if isinstance(msg, BluetoothDeviceConnectionResponse):
resp = BluetoothDeviceConnection.from_pb(msg)
if address == resp.address:
on_bluetooth_connection_state(resp.connected, resp.mtu, resp.error)
event.set()
assert self._connection is not None
await self._connection.send_message_callback_response(
BluetoothDeviceRequest(
address=address,
request_type=BluetoothDeviceRequestType.CONNECT,
),
on_msg,
)
try:
async with async_timeout.timeout(timeout):
await event.wait()
except asyncio.TimeoutError as err:
raise TimeoutAPIError("Timeout waiting for connect response") from err
def unsub() -> None:
assert self._connection is not None
self._connection.remove_message_callback(on_msg)
return unsub
async def bluetooth_device_disconnect(self, address: int) -> None:
self._check_authenticated()
assert self._connection is not None
await self._connection.send_message(
BluetoothDeviceRequest(
address=address,
request_type=BluetoothDeviceRequestType.DISCONNECT,
)
)
async def bluetooth_gatt_get_services(self, address: int) -> BluetoothGATTServices:
self._check_authenticated()
def do_append(msg: message.Message) -> bool:
return isinstance(msg, BluetoothGATTGetServicesResponse)
def do_stop(msg: message.Message) -> bool:
return isinstance(msg, BluetoothGATTGetServicesDoneResponse)
assert self._connection is not None
resp = await self._connection.send_message_await_response_complex(
BluetoothGATTGetServicesRequest(address=address), do_append, do_stop
)
services = []
for msg in resp:
services.extend(BluetoothGATTServices.from_pb(msg).services)
return BluetoothGATTServices(address=address, services=services)
async def bluetooth_gatt_read(
self, address: int, characteristic_handle: int, timeout: float = 10.0
) -> bytearray:
self._check_authenticated()
req = BluetoothGATTReadRequest()
req.address = address
req.handle = characteristic_handle
def is_response(msg: message.Message) -> bool:
if isinstance(msg, BluetoothGATTReadResponse):
read = BluetoothGATTRead.from_pb(msg)
if read.address == address and read.handle == characteristic_handle:
return True
return False
assert self._connection is not None
resp = await self._connection.send_message_await_response_complex(
req, is_response, is_response, timeout=timeout
)
if len(resp) != 1:
raise APIConnectionError(f"Expected one result, got {len(resp)}")
read_response = BluetoothGATTRead.from_pb(resp[0])
return bytearray(read_response.data)
async def bluetooth_gatt_write(
self,
address: int,
characteristic_handle: int,
data: bytes,
response: bool,
) -> None:
self._check_authenticated()
req = BluetoothGATTWriteRequest()
req.address = address
req.handle = characteristic_handle
req.response = response
req.data = data
assert self._connection is not None
await self._connection.send_message(req)
async def bluetooth_gatt_read_descriptor(
self,
address: int,
handle: int,
timeout: float = 10.0,
) -> bytearray:
self._check_authenticated()
req = BluetoothGATTReadDescriptorRequest()
req.address = address
req.handle = handle
def is_response(msg: message.Message) -> bool:
if isinstance(msg, BluetoothGATTReadResponse):
read = BluetoothGATTRead.from_pb(msg)
if read.address == address and read.handle == handle:
return True
return False
assert self._connection is not None
resp = await self._connection.send_message_await_response_complex(
req, is_response, is_response, timeout=timeout
)
if len(resp) != 1:
raise APIConnectionError(f"Expected one result, got {len(resp)}")
read_response = BluetoothGATTRead.from_pb(resp[0])
return bytearray(read_response.data)
async def bluetooth_gatt_write_descriptor(
self,
address: int,
handle: int,
data: bytes,
) -> None:
self._check_authenticated()
req = BluetoothGATTWriteDescriptorRequest()
req.address = address
req.handle = handle
req.data = data
assert self._connection is not None
await self._connection.send_message(req)
async def bluetooth_gatt_start_notify(
self,
address: int,
handle: int,
on_bluetooth_gatt_notify: Callable[[int, bytearray], None],
) -> Callable[[], Coroutine[Any, Any, None]]:
self._check_authenticated()
def on_msg(msg: message.Message) -> None:
if isinstance(msg, BluetoothGATTNotifyDataResponse):
notify = BluetoothGATTRead.from_pb(msg)
if address == notify.address and handle == notify.handle:
on_bluetooth_gatt_notify(handle, bytearray(notify.data))
assert self._connection is not None
await self._connection.send_message_callback_response(
BluetoothGATTNotifyRequest(address=address, handle=handle, enable=True),
on_msg,
)
async def stop_notify() -> None:
assert self._connection is not None
self._connection.remove_message_callback(on_msg)
self._check_authenticated()
await self._connection.send_message(
BluetoothGATTNotifyRequest(address=address, handle=handle, enable=False)
)
return stop_notify
async def subscribe_home_assistant_states( async def subscribe_home_assistant_states(
self, on_state_sub: Callable[[str, Optional[str]], None] self, on_state_sub: Callable[[str, Optional[str]], None]
) -> None: ) -> None:

View File

@ -362,6 +362,10 @@ class APIConnection:
await self._report_fatal_error(err) await self._report_fatal_error(err)
raise raise
def remove_message_callback(self, on_message: Callable[[Any], None]) -> None:
"""Remove a message callback."""
self._message_handlers.remove(on_message)
async def send_message_callback_response( async def send_message_callback_response(
self, send_msg: message.Message, on_message: Callable[[Any], None] self, send_msg: message.Message, on_message: Callable[[Any], None]
) -> None: ) -> None:

View File

@ -1,5 +1,18 @@
from .api_pb2 import ( # type: ignore from .api_pb2 import ( # type: ignore
BinarySensorStateResponse, BinarySensorStateResponse,
BluetoothConnectionsFreeResponse,
BluetoothDeviceConnectionResponse,
BluetoothDeviceRequest,
BluetoothGATTGetServicesDoneResponse,
BluetoothGATTGetServicesRequest,
BluetoothGATTGetServicesResponse,
BluetoothGATTNotifyDataResponse,
BluetoothGATTNotifyRequest,
BluetoothGATTReadDescriptorRequest,
BluetoothGATTReadRequest,
BluetoothGATTReadResponse,
BluetoothGATTWriteDescriptorRequest,
BluetoothGATTWriteRequest,
BluetoothLEAdvertisementResponse, BluetoothLEAdvertisementResponse,
ButtonCommandRequest, ButtonCommandRequest,
CameraImageRequest, CameraImageRequest,
@ -56,6 +69,7 @@ from .api_pb2 import ( # type: ignore
SensorStateResponse, SensorStateResponse,
SirenCommandRequest, SirenCommandRequest,
SirenStateResponse, SirenStateResponse,
SubscribeBluetoothConnectionsFreeRequest,
SubscribeBluetoothLEAdvertisementsRequest, SubscribeBluetoothLEAdvertisementsRequest,
SubscribeHomeassistantServicesRequest, SubscribeHomeassistantServicesRequest,
SubscribeHomeAssistantStateResponse, SubscribeHomeAssistantStateResponse,
@ -193,4 +207,18 @@ MESSAGE_TYPE_TO_PROTO = {
65: MediaPlayerCommandRequest, 65: MediaPlayerCommandRequest,
66: SubscribeBluetoothLEAdvertisementsRequest, 66: SubscribeBluetoothLEAdvertisementsRequest,
67: BluetoothLEAdvertisementResponse, 67: BluetoothLEAdvertisementResponse,
68: BluetoothDeviceRequest,
69: BluetoothDeviceConnectionResponse,
70: BluetoothGATTGetServicesRequest,
71: BluetoothGATTGetServicesResponse,
72: BluetoothGATTGetServicesDoneResponse,
73: BluetoothGATTReadRequest,
74: BluetoothGATTReadResponse,
75: BluetoothGATTWriteRequest,
76: BluetoothGATTReadDescriptorRequest,
77: BluetoothGATTWriteDescriptorRequest,
78: BluetoothGATTNotifyRequest,
79: BluetoothGATTNotifyDataResponse,
80: SubscribeBluetoothConnectionsFreeRequest,
81: BluetoothConnectionsFreeResponse,
} }

View File

@ -759,7 +759,7 @@ def _long_uuid(uuid: str) -> str:
"""Convert a UUID to a long UUID.""" """Convert a UUID to a long UUID."""
return ( return (
f"0000{uuid[2:].lower()}-0000-1000-8000-00805f9b34fb" if len(uuid) < 8 else uuid f"0000{uuid[2:].lower()}-0000-1000-8000-00805f9b34fb" if len(uuid) < 8 else uuid
) ).lower()
def _convert_bluetooth_le_service_uuids(value: List[str]) -> List[str]: def _convert_bluetooth_le_service_uuids(value: List[str]) -> List[str]:
@ -771,6 +771,7 @@ def _convert_bluetooth_le_service_data(
) -> Dict[str, bytes]: ) -> Dict[str, bytes]:
if isinstance(value, dict): if isinstance(value, dict):
return value return value
return {_long_uuid(v.uuid): bytes(v.data) for v in value} # type: ignore return {_long_uuid(v.uuid): bytes(v.data) for v in value} # type: ignore
@ -799,6 +800,100 @@ class BluetoothLEAdvertisement(APIModelBase):
) )
@dataclass(frozen=True)
class BluetoothDeviceConnection(APIModelBase):
address: int = 0
connected: bool = False
mtu: int = 0
error: int = 0
@dataclass(frozen=True)
class BluetoothGATTRead(APIModelBase):
address: int = 0
handle: int = 0
data: bytes = b""
@dataclass(frozen=True)
class BluetoothGATTDescriptor(APIModelBase):
uuid: str = converter_field(default="", converter=_long_uuid)
handle: int = 0
description: str = ""
@classmethod
def convert_list(cls, value: List[Any]) -> List["BluetoothGATTDescriptor"]:
ret = []
for x in value:
if isinstance(x, dict):
ret.append(cls.from_dict(x))
else:
ret.append(cls.from_pb(x))
return ret
@dataclass(frozen=True)
class BluetoothGATTCharacteristic(APIModelBase):
uuid: str = converter_field(default="", converter=_long_uuid)
handle: int = 0
properties: int = 0
descriptors: List[BluetoothGATTDescriptor] = converter_field(
default_factory=list, converter=BluetoothGATTDescriptor.convert_list
)
@classmethod
def convert_list(cls, value: List[Any]) -> List["BluetoothGATTCharacteristic"]:
ret = []
for x in value:
if isinstance(x, dict):
ret.append(cls.from_dict(x))
else:
ret.append(cls.from_pb(x))
return ret
@dataclass(frozen=True)
class BluetoothGATTService(APIModelBase):
uuid: str = converter_field(default="", converter=_long_uuid)
handle: int = 0
characteristics: List[BluetoothGATTCharacteristic] = converter_field(
default_factory=list, converter=BluetoothGATTCharacteristic.convert_list
)
@classmethod
def convert_list(cls, value: List[Any]) -> List["BluetoothGATTService"]:
ret = []
for x in value:
if isinstance(x, dict):
ret.append(cls.from_dict(x))
else:
ret.append(cls.from_pb(x))
return ret
@dataclass(frozen=True)
class BluetoothGATTServices(APIModelBase):
address: int = 0
services: List[BluetoothGATTService] = converter_field(
default_factory=list, converter=BluetoothGATTService.convert_list
)
@dataclass(frozen=True)
class BluetoothConnectionsFree(APIModelBase):
free: int = 0
limit: int = 0
class BluetoothDeviceRequestType(APIIntEnum):
CONNECT = 0
DISCONNECT = 1
PAIR = 2
UNPAIR = 3
class LogLevel(APIIntEnum): class LogLevel(APIIntEnum):
LOG_LEVEL_NONE = 0 LOG_LEVEL_NONE = 0
LOG_LEVEL_ERROR = 1 LOG_LEVEL_ERROR = 1