bumble 0.0.198__py3-none-any.whl → 0.0.199__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.
@@ -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]