bumble 0.0.198__py3-none-any.whl → 0.0.200__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bumble/host.py CHANGED
@@ -171,7 +171,7 @@ class Host(AbortableEventEmitter):
171
171
  self.cis_links = {} # CIS links, by connection handle
172
172
  self.sco_links = {} # SCO links, by connection handle
173
173
  self.pending_command = None
174
- self.pending_response = None
174
+ self.pending_response: Optional[asyncio.Future[Any]] = None
175
175
  self.number_of_supported_advertising_sets = 0
176
176
  self.maximum_advertising_data_length = 31
177
177
  self.local_version = None
@@ -514,7 +514,9 @@ class Host(AbortableEventEmitter):
514
514
  if self.hci_sink:
515
515
  self.hci_sink.on_packet(bytes(packet))
516
516
 
517
- async def send_command(self, command, check_result=False):
517
+ async def send_command(
518
+ self, command, check_result=False, response_timeout: Optional[int] = None
519
+ ):
518
520
  # Wait until we can send (only one pending command at a time)
519
521
  async with self.command_semaphore:
520
522
  assert self.pending_command is None
@@ -526,12 +528,13 @@ class Host(AbortableEventEmitter):
526
528
 
527
529
  try:
528
530
  self.send_hci_packet(command)
529
- response = await self.pending_response
531
+ await asyncio.wait_for(self.pending_response, timeout=response_timeout)
532
+ response = self.pending_response.result()
530
533
 
531
534
  # Check the return parameters if required
532
535
  if check_result:
533
536
  if isinstance(response, hci.HCI_Command_Status_Event):
534
- status = response.status
537
+ status = response.status # type: ignore[attr-defined]
535
538
  elif isinstance(response.return_parameters, int):
536
539
  status = response.return_parameters
537
540
  elif isinstance(response.return_parameters, bytes):
@@ -625,14 +628,21 @@ class Host(AbortableEventEmitter):
625
628
 
626
629
  # Packet Sink protocol (packets coming from the controller via HCI)
627
630
  def on_packet(self, packet: bytes) -> None:
628
- hci_packet = hci.HCI_Packet.from_bytes(packet)
631
+ try:
632
+ hci_packet = hci.HCI_Packet.from_bytes(packet)
633
+ except Exception as error:
634
+ logger.warning(f'!!! error parsing packet from bytes: {error}')
635
+ return
636
+
629
637
  if self.ready or (
630
638
  isinstance(hci_packet, hci.HCI_Command_Complete_Event)
631
639
  and hci_packet.command_opcode == hci.HCI_RESET_COMMAND
632
640
  ):
633
641
  self.on_hci_packet(hci_packet)
634
642
  else:
635
- logger.debug('reset not done, ignoring packet from controller')
643
+ logger.debug(
644
+ f'reset not done, ignoring packet from controller: {hci_packet}'
645
+ )
636
646
 
637
647
  def on_transport_lost(self):
638
648
  # Called by the source when the transport has been lost.
@@ -1096,6 +1106,18 @@ class Host(AbortableEventEmitter):
1096
1106
  event.status,
1097
1107
  )
1098
1108
 
1109
+ def on_hci_qos_setup_complete_event(self, event):
1110
+ if event.status == hci.HCI_SUCCESS:
1111
+ self.emit(
1112
+ 'connection_qos_setup', event.connection_handle, event.service_type
1113
+ )
1114
+ else:
1115
+ self.emit(
1116
+ 'connection_qos_setup_failure',
1117
+ event.connection_handle,
1118
+ event.status,
1119
+ )
1120
+
1099
1121
  def on_hci_link_supervision_timeout_changed_event(self, event):
1100
1122
  pass
1101
1123
 
@@ -25,8 +25,10 @@ import grpc.aio
25
25
  from .config import Config
26
26
  from .device import PandoraDevice
27
27
  from .host import HostService
28
+ from .l2cap import L2CAPService
28
29
  from .security import SecurityService, SecurityStorageService
29
30
  from pandora.host_grpc_aio import add_HostServicer_to_server
31
+ from pandora.l2cap_grpc_aio import add_L2CAPServicer_to_server
30
32
  from pandora.security_grpc_aio import (
31
33
  add_SecurityServicer_to_server,
32
34
  add_SecurityStorageServicer_to_server,
@@ -77,6 +79,7 @@ async def serve(
77
79
  add_SecurityStorageServicer_to_server(
78
80
  SecurityStorageService(bumble.device, config), server
79
81
  )
82
+ add_L2CAPServicer_to_server(L2CAPService(bumble.device, config), server)
80
83
 
81
84
  # call hooks if any.
82
85
  for hook in _SERVICERS_HOOKS:
@@ -0,0 +1,310 @@
1
+ # Copyright 2024 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from __future__ import annotations
15
+ import asyncio
16
+ import grpc
17
+ import json
18
+ import logging
19
+
20
+ from asyncio import Queue as AsyncQueue, Future
21
+
22
+ from . import utils
23
+ from .config import Config
24
+ from bumble.core import OutOfResourcesError, InvalidArgumentError
25
+ from bumble.device import Device
26
+ from bumble.l2cap import (
27
+ ClassicChannel,
28
+ ClassicChannelServer,
29
+ ClassicChannelSpec,
30
+ LeCreditBasedChannel,
31
+ LeCreditBasedChannelServer,
32
+ LeCreditBasedChannelSpec,
33
+ )
34
+ from google.protobuf import any_pb2, empty_pb2 # pytype: disable=pyi-error
35
+ from pandora.l2cap_grpc_aio import L2CAPServicer # pytype: disable=pyi-error
36
+ from pandora.l2cap_pb2 import ( # pytype: disable=pyi-error
37
+ COMMAND_NOT_UNDERSTOOD,
38
+ INVALID_CID_IN_REQUEST,
39
+ Channel as PandoraChannel,
40
+ ConnectRequest,
41
+ ConnectResponse,
42
+ CreditBasedChannelRequest,
43
+ DisconnectRequest,
44
+ DisconnectResponse,
45
+ ReceiveRequest,
46
+ ReceiveResponse,
47
+ SendRequest,
48
+ SendResponse,
49
+ WaitConnectionRequest,
50
+ WaitConnectionResponse,
51
+ WaitDisconnectionRequest,
52
+ WaitDisconnectionResponse,
53
+ )
54
+ from typing import AsyncGenerator, Dict, Optional, Union
55
+ from dataclasses import dataclass
56
+
57
+ L2capChannel = Union[ClassicChannel, LeCreditBasedChannel]
58
+
59
+
60
+ @dataclass
61
+ class ChannelContext:
62
+ close_future: Future
63
+ sdu_queue: AsyncQueue
64
+
65
+
66
+ class L2CAPService(L2CAPServicer):
67
+ def __init__(self, device: Device, config: Config) -> None:
68
+ self.log = utils.BumbleServerLoggerAdapter(
69
+ logging.getLogger(), {'service_name': 'L2CAP', 'device': device}
70
+ )
71
+ self.device = device
72
+ self.config = config
73
+ self.channels: Dict[bytes, ChannelContext] = {}
74
+
75
+ def register_event(self, l2cap_channel: L2capChannel) -> ChannelContext:
76
+ close_future = asyncio.get_running_loop().create_future()
77
+ sdu_queue: AsyncQueue = AsyncQueue()
78
+
79
+ def on_channel_sdu(sdu):
80
+ sdu_queue.put_nowait(sdu)
81
+
82
+ def on_close():
83
+ close_future.set_result(None)
84
+
85
+ l2cap_channel.sink = on_channel_sdu
86
+ l2cap_channel.on('close', on_close)
87
+
88
+ return ChannelContext(close_future, sdu_queue)
89
+
90
+ @utils.rpc
91
+ async def WaitConnection(
92
+ self, request: WaitConnectionRequest, context: grpc.ServicerContext
93
+ ) -> WaitConnectionResponse:
94
+ self.log.debug('WaitConnection')
95
+ if not request.connection:
96
+ raise ValueError('A valid connection field must be set')
97
+
98
+ # find connection on device based on connection cookie value
99
+ connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
100
+ connection = self.device.lookup_connection(connection_handle)
101
+
102
+ if not connection:
103
+ raise ValueError('The connection specified is invalid.')
104
+
105
+ oneof = request.WhichOneof('type')
106
+ self.log.debug(f'WaitConnection channel request type: {oneof}.')
107
+ channel_type = getattr(request, oneof)
108
+ spec: Optional[Union[ClassicChannelSpec, LeCreditBasedChannelSpec]] = None
109
+ l2cap_server: Optional[
110
+ Union[ClassicChannelServer, LeCreditBasedChannelServer]
111
+ ] = None
112
+ if isinstance(channel_type, CreditBasedChannelRequest):
113
+ spec = LeCreditBasedChannelSpec(
114
+ psm=channel_type.spsm,
115
+ max_credits=channel_type.initial_credit,
116
+ mtu=channel_type.mtu,
117
+ mps=channel_type.mps,
118
+ )
119
+ if channel_type.spsm in self.device.l2cap_channel_manager.le_coc_servers:
120
+ l2cap_server = self.device.l2cap_channel_manager.le_coc_servers[
121
+ channel_type.spsm
122
+ ]
123
+ else:
124
+ spec = ClassicChannelSpec(
125
+ psm=channel_type.psm,
126
+ mtu=channel_type.mtu,
127
+ )
128
+ if channel_type.psm in self.device.l2cap_channel_manager.servers:
129
+ l2cap_server = self.device.l2cap_channel_manager.servers[
130
+ channel_type.psm
131
+ ]
132
+
133
+ self.log.info(f'Listening for L2CAP connection on PSM {spec.psm}')
134
+ channel_future: Future[PandoraChannel] = (
135
+ asyncio.get_running_loop().create_future()
136
+ )
137
+
138
+ def on_l2cap_channel(l2cap_channel: L2capChannel):
139
+ try:
140
+ channel_context = self.register_event(l2cap_channel)
141
+ pandora_channel: PandoraChannel = self.craft_pandora_channel(
142
+ connection_handle, l2cap_channel
143
+ )
144
+ self.channels[pandora_channel.cookie.value] = channel_context
145
+ channel_future.set_result(pandora_channel)
146
+ except Exception as e:
147
+ self.log.error(f'Failed to set channel future: {e}')
148
+
149
+ if l2cap_server is None:
150
+ l2cap_server = self.device.create_l2cap_server(
151
+ spec=spec, handler=on_l2cap_channel
152
+ )
153
+ else:
154
+ l2cap_server.on('connection', on_l2cap_channel)
155
+
156
+ try:
157
+ self.log.debug('Waiting for a channel connection.')
158
+ pandora_channel: PandoraChannel = await channel_future
159
+
160
+ return WaitConnectionResponse(channel=pandora_channel)
161
+ except Exception as e:
162
+ self.log.warning(f'Exception: {e}')
163
+
164
+ return WaitConnectionResponse(error=COMMAND_NOT_UNDERSTOOD)
165
+
166
+ @utils.rpc
167
+ async def WaitDisconnection(
168
+ self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
169
+ ) -> WaitDisconnectionResponse:
170
+ try:
171
+ self.log.debug('WaitDisconnection')
172
+
173
+ await self.lookup_context(request.channel).close_future
174
+ self.log.debug("return WaitDisconnectionResponse")
175
+ return WaitDisconnectionResponse(success=empty_pb2.Empty())
176
+ except KeyError as e:
177
+ self.log.warning(f'WaitDisconnection: Unable to find the channel: {e}')
178
+ return WaitDisconnectionResponse(error=INVALID_CID_IN_REQUEST)
179
+ except Exception as e:
180
+ self.log.exception(f'WaitDisonnection failed: {e}')
181
+ return WaitDisconnectionResponse(error=COMMAND_NOT_UNDERSTOOD)
182
+
183
+ @utils.rpc
184
+ async def Receive(
185
+ self, request: ReceiveRequest, context: grpc.ServicerContext
186
+ ) -> AsyncGenerator[ReceiveResponse, None]:
187
+ self.log.debug('Receive')
188
+ oneof = request.WhichOneof('source')
189
+ self.log.debug(f'Source: {oneof}.')
190
+ pandora_channel = getattr(request, oneof)
191
+
192
+ sdu_queue = self.lookup_context(pandora_channel).sdu_queue
193
+
194
+ while sdu := await sdu_queue.get():
195
+ self.log.debug(f'Receive: Received {len(sdu)} bytes -> {sdu.decode()}')
196
+ response = ReceiveResponse(data=sdu)
197
+ yield response
198
+
199
+ @utils.rpc
200
+ async def Connect(
201
+ self, request: ConnectRequest, context: grpc.ServicerContext
202
+ ) -> ConnectResponse:
203
+ self.log.debug('Connect')
204
+
205
+ if not request.connection:
206
+ raise ValueError('A valid connection field must be set')
207
+
208
+ # find connection on device based on connection cookie value
209
+ connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
210
+ connection = self.device.lookup_connection(connection_handle)
211
+
212
+ if not connection:
213
+ raise ValueError('The connection specified is invalid.')
214
+
215
+ oneof = request.WhichOneof('type')
216
+ self.log.debug(f'Channel request type: {oneof}.')
217
+ channel_type = getattr(request, oneof)
218
+ spec: Optional[Union[ClassicChannelSpec, LeCreditBasedChannelSpec]] = None
219
+ if isinstance(channel_type, CreditBasedChannelRequest):
220
+ spec = LeCreditBasedChannelSpec(
221
+ psm=channel_type.spsm,
222
+ max_credits=channel_type.initial_credit,
223
+ mtu=channel_type.mtu,
224
+ mps=channel_type.mps,
225
+ )
226
+ else:
227
+ spec = ClassicChannelSpec(
228
+ psm=channel_type.psm,
229
+ mtu=channel_type.mtu,
230
+ )
231
+
232
+ try:
233
+ self.log.info(f'Opening L2CAP channel on PSM = {spec.psm}')
234
+ l2cap_channel = await connection.create_l2cap_channel(spec=spec)
235
+ channel_context = self.register_event(l2cap_channel)
236
+ pandora_channel = self.craft_pandora_channel(
237
+ connection_handle, l2cap_channel
238
+ )
239
+ self.channels[pandora_channel.cookie.value] = channel_context
240
+
241
+ return ConnectResponse(channel=pandora_channel)
242
+
243
+ except OutOfResourcesError as e:
244
+ self.log.error(e)
245
+ return ConnectResponse(error=INVALID_CID_IN_REQUEST)
246
+ except InvalidArgumentError as e:
247
+ self.log.error(e)
248
+ return ConnectResponse(error=COMMAND_NOT_UNDERSTOOD)
249
+
250
+ @utils.rpc
251
+ async def Disconnect(
252
+ self, request: DisconnectRequest, context: grpc.ServicerContext
253
+ ) -> DisconnectResponse:
254
+ try:
255
+ self.log.debug('Disconnect')
256
+ l2cap_channel = self.lookup_channel(request.channel)
257
+ if not l2cap_channel:
258
+ self.log.warning('Disconnect: Unable to find the channel')
259
+ return DisconnectResponse(error=INVALID_CID_IN_REQUEST)
260
+
261
+ await l2cap_channel.disconnect()
262
+ return DisconnectResponse(success=empty_pb2.Empty())
263
+ except Exception as e:
264
+ self.log.exception(f'Disonnect failed: {e}')
265
+ return DisconnectResponse(error=COMMAND_NOT_UNDERSTOOD)
266
+
267
+ @utils.rpc
268
+ async def Send(
269
+ self, request: SendRequest, context: grpc.ServicerContext
270
+ ) -> SendResponse:
271
+ self.log.debug('Send')
272
+ try:
273
+ oneof = request.WhichOneof('sink')
274
+ self.log.debug(f'Sink: {oneof}.')
275
+ pandora_channel = getattr(request, oneof)
276
+
277
+ l2cap_channel = self.lookup_channel(pandora_channel)
278
+ if not l2cap_channel:
279
+ return SendResponse(error=COMMAND_NOT_UNDERSTOOD)
280
+ if isinstance(l2cap_channel, ClassicChannel):
281
+ l2cap_channel.send_pdu(request.data)
282
+ else:
283
+ l2cap_channel.write(request.data)
284
+ return SendResponse(success=empty_pb2.Empty())
285
+ except Exception as e:
286
+ self.log.exception(f'Disonnect failed: {e}')
287
+ return SendResponse(error=COMMAND_NOT_UNDERSTOOD)
288
+
289
+ def craft_pandora_channel(
290
+ self,
291
+ connection_handle: int,
292
+ l2cap_channel: L2capChannel,
293
+ ) -> PandoraChannel:
294
+ parameters = {
295
+ "connection_handle": connection_handle,
296
+ "source_cid": l2cap_channel.source_cid,
297
+ }
298
+ cookie = any_pb2.Any()
299
+ cookie.value = json.dumps(parameters).encode()
300
+ return PandoraChannel(cookie=cookie)
301
+
302
+ def lookup_channel(self, pandora_channel: PandoraChannel) -> L2capChannel:
303
+ (connection_handle, source_cid) = json.loads(
304
+ pandora_channel.cookie.value
305
+ ).values()
306
+
307
+ return self.device.l2cap_channel_manager.channels[connection_handle][source_cid]
308
+
309
+ def lookup_context(self, pandora_channel: PandoraChannel) -> ChannelContext:
310
+ return self.channels[pandora_channel.cookie.value]