Skip to content

Commit 029a5e0

Browse files
committed
Implement L2CAP service
1 parent 6616477 commit 029a5e0

3 files changed

Lines changed: 325 additions & 1 deletion

File tree

bumble/pandora/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@
2626
from .device import PandoraDevice
2727
from .host import HostService
2828
from .security import SecurityService, SecurityStorageService
29+
from .l2cap import L2CAPService
2930
from pandora.host_grpc_aio import add_HostServicer_to_server
3031
from pandora.security_grpc_aio import (
3132
add_SecurityServicer_to_server,
3233
add_SecurityStorageServicer_to_server,
3334
)
35+
from pandora.l2cap_grpc_aio import add_L2CAPServicer_to_server
3436
from typing import Callable, List, Optional
3537

3638
# public symbols
@@ -77,6 +79,10 @@ async def serve(
7779
add_SecurityStorageServicer_to_server(
7880
SecurityStorageService(bumble.device, config), server
7981
)
82+
add_SecurityStorageServicer_to_server(
83+
SecurityStorageService(bumble.device, config), server
84+
)
85+
add_L2CAPServicer_to_server(L2CAPService(bumble.device, config), server)
8086

8187
# call hooks if any.
8288
for hook in _SERVICERS_HOOKS:

bumble/pandora/l2cap.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
# Copyright 2023 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+
15+
import abc
16+
import asyncio
17+
import collections
18+
import dataclasses
19+
import grpc
20+
import logging
21+
import struct
22+
23+
from bumble import device
24+
from bumble import l2cap
25+
from bumble.utils import EventWatcher
26+
from bumble.pandora import config
27+
from bumble.pandora import utils
28+
from google.protobuf import any_pb2 # pytype: disable=pyi-error
29+
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
30+
from pandora import l2cap_pb2
31+
from pandora import l2cap_grpc_aio
32+
from typing import AsyncGenerator, Dict, Union, Optional, DefaultDict
33+
34+
35+
class ChannelProxy(abc.ABC):
36+
up_queue: asyncio.Queue[bytes] = asyncio.Queue()
37+
38+
def send(self, sdu: bytes) -> None:
39+
...
40+
41+
async def receive(self) -> bytes:
42+
return await self.up_queue.get()
43+
44+
def on_data(self, pdu: bytes) -> None:
45+
self.up_queue.put_nowait(pdu)
46+
47+
48+
class CocChannelProxy(ChannelProxy):
49+
def __init__(
50+
self, channel: Union[l2cap.ClassicChannel, l2cap.LeCreditBasedChannel]
51+
) -> None:
52+
super().__init__()
53+
self.channel = channel
54+
channel.sink = self.on_data
55+
self.disconnection_result = asyncio.get_event_loop().create_future()
56+
57+
@channel.once('close')
58+
def on_close() -> None:
59+
self.disconnection_result.set_result(None)
60+
61+
def send(self, data: bytes) -> None:
62+
if isinstance(self.channel, l2cap.ClassicChannel):
63+
self.channel.send_pdu(data)
64+
else:
65+
self.channel.write(data)
66+
67+
@property
68+
def closed(self):
69+
if isinstance(self.channel, l2cap.ClassicChannel):
70+
return self.channel.state == self.channel.State.CLOSED
71+
else:
72+
return self.channel.state == self.channel.State.DISCONNECTED
73+
74+
async def disconnect(self) -> None:
75+
if self.closed:
76+
return
77+
78+
await self.channel.disconnect()
79+
80+
async def wait_disconnect(self) -> None:
81+
if self.closed:
82+
return
83+
84+
await self.disconnection_result
85+
86+
87+
@dataclasses.dataclass
88+
class FixedChannelProxy(ChannelProxy):
89+
connection_handle: int
90+
cid: int
91+
device: device.Device
92+
93+
def send(self, data: bytes) -> None:
94+
self.device.send_l2cap_pdu(self.connection_handle, self.cid, data)
95+
96+
97+
class L2CAPService(l2cap_grpc_aio.L2CAPServicer):
98+
channels: DefaultDict[int, Dict[int, ChannelProxy]]
99+
100+
def __init__(self, device: device.Device, config: config.Config) -> None:
101+
self.log = utils.BumbleServerLoggerAdapter(
102+
logging.getLogger(), {'service_name': 'L2CAP', 'device': device}
103+
)
104+
self.device = device
105+
self.config = config
106+
self.channels = collections.defaultdict(dict)
107+
108+
def get_channel(self, channel: l2cap_pb2.Channel) -> ChannelProxy:
109+
connection_handle, cid = struct.unpack('>HH', channel.cookie.value)
110+
if cid not in self.channels[connection_handle]:
111+
raise RuntimeError('No valid cid or handle')
112+
return self.channels[connection_handle][cid]
113+
114+
@utils.rpc
115+
async def Connect(
116+
self, request: l2cap_pb2.ConnectRequest, context: grpc.ServicerContext
117+
) -> l2cap_pb2.ConnectResponse:
118+
self.log.info('Connect')
119+
channel: Union[
120+
FixedChannelProxy, l2cap.ClassicChannel, l2cap.LeCreditBasedChannel
121+
]
122+
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
123+
124+
connection = self.device.lookup_connection(connection_handle)
125+
if connection is None:
126+
raise RuntimeError('Connection not exist')
127+
128+
if request.type_variant() == 'fixed':
129+
# For fixed channel connection, do nothing because it's connectionless
130+
assert request.fixed
131+
cid = request.fixed.cid
132+
l2cap_cookie = any_pb2.Any(value=struct.pack('>HH', connection_handle, cid))
133+
self.channels[connection_handle][cid] = FixedChannelProxy(
134+
connection_handle=connection_handle,
135+
cid=cid,
136+
device=self.device,
137+
)
138+
139+
def on_fixed_pdu(connection_handle: int, pdu: bytes) -> None:
140+
self.channels[connection_handle][cid].on_data(pdu)
141+
142+
self.device.l2cap_channel_manager.register_fixed_channel(cid, on_fixed_pdu)
143+
return l2cap_pb2.ConnectResponse(
144+
channel=l2cap_pb2.Channel(cookie=l2cap_cookie)
145+
)
146+
147+
if request.type_variant() == 'basic':
148+
assert request.basic
149+
channel = await connection.create_l2cap_channel(
150+
spec=l2cap.ClassicChannelSpec(
151+
psm=request.basic.psm, mtu=request.basic.mtu
152+
)
153+
)
154+
elif request.type_variant() == 'le_credit_based':
155+
assert request.le_credit_based
156+
channel = await connection.create_l2cap_channel(
157+
spec=l2cap.LeCreditBasedChannelSpec(
158+
psm=request.le_credit_based.spsm,
159+
max_credits=request.le_credit_based.initial_credit,
160+
mtu=request.le_credit_based.mtu,
161+
mps=request.le_credit_based.mps,
162+
)
163+
)
164+
else:
165+
raise NotImplementedError
166+
167+
self.channels[connection_handle][channel.source_cid] = CocChannelProxy(channel)
168+
l2cap_cookie = any_pb2.Any(
169+
value=struct.pack('>HH', connection_handle, channel.source_cid)
170+
)
171+
return l2cap_pb2.ConnectResponse(channel=l2cap_pb2.Channel(cookie=l2cap_cookie))
172+
173+
@utils.rpc
174+
async def OnConnection(
175+
self, request: l2cap_pb2.OnConnectionRequest, context: grpc.ServicerContext
176+
) -> AsyncGenerator[l2cap_pb2.OnConnectionResponse, None]:
177+
self.log.info('WaitConnection')
178+
179+
queue: asyncio.Queue[l2cap_pb2.OnConnectionResponse] = asyncio.Queue()
180+
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
181+
182+
connection = self.device.lookup_connection(connection_handle)
183+
if connection is None:
184+
raise RuntimeError('Connection not exist')
185+
186+
self.channels.setdefault(connection_handle, {})
187+
188+
watcher = EventWatcher()
189+
server: Union[
190+
l2cap.ClassicChannelServer, l2cap.LeCreditBasedChannelServer, None
191+
] = None
192+
fixed_cid: Optional[int] = None
193+
194+
# Fixed channels are connectionless, so it should produce a response immediately.
195+
if request.type_variant() == 'fixed':
196+
assert request.fixed
197+
cid = request.fixed.cid
198+
199+
def on_fixed_pdu(connection_handle: int, pdu: bytes) -> None:
200+
self.channels[connection_handle][cid].on_data(pdu)
201+
202+
channel_proxy = FixedChannelProxy(
203+
connection_handle=connection_handle, cid=cid, device=self.device
204+
)
205+
self.channels[connection_handle][cid] = channel_proxy
206+
l2cap_cookie = any_pb2.Any(value=struct.pack('>HH', connection_handle, cid))
207+
208+
# Register CID and callback
209+
self.device.l2cap_channel_manager.register_fixed_channel(cid, on_fixed_pdu)
210+
211+
queue.put_nowait(
212+
l2cap_pb2.OnConnectionResponse(
213+
channel=l2cap_pb2.Channel(cookie=l2cap_cookie)
214+
)
215+
)
216+
else:
217+
218+
def on_connected(
219+
channel: Union[l2cap.ClassicChannel, l2cap.LeCreditBasedChannel]
220+
) -> None:
221+
handle = channel.connection.handle
222+
# Filter channels from non-specified connection
223+
if handle != connection_handle:
224+
connection.abort_on('disconnect', channel.disconnect())
225+
return
226+
227+
# Save channel instances
228+
cid = channel.source_cid
229+
self.channels[handle][cid] = CocChannelProxy(channel)
230+
231+
# Produce connection responses
232+
l2cap_cookie = any_pb2.Any(value=struct.pack('>HH', handle, cid))
233+
queue.put_nowait(
234+
l2cap_pb2.OnConnectionResponse(
235+
channel=l2cap_pb2.Channel(cookie=l2cap_cookie)
236+
)
237+
)
238+
239+
# Listen disconnections
240+
@watcher.on(channel, 'close')
241+
def on_close():
242+
del self.channels[handle][cid]
243+
244+
if request.type_variant() == 'basic':
245+
assert request.basic
246+
server = self.device.create_l2cap_server(
247+
spec=l2cap.ClassicChannelSpec(psm=request.basic.psm),
248+
handler=on_connected,
249+
)
250+
elif request.type_variant() == 'le_credit_based':
251+
assert request.le_credit_based
252+
server = self.device.create_l2cap_server(
253+
spec=l2cap.LeCreditBasedChannelSpec(
254+
psm=request.le_credit_based.spsm,
255+
max_credits=request.le_credit_based.initial_credit,
256+
mtu=request.le_credit_based.mtu,
257+
mps=request.le_credit_based.mps,
258+
),
259+
handler=on_connected,
260+
)
261+
else:
262+
raise NotImplementedError
263+
264+
try:
265+
# Produce event stream
266+
while event := await queue.get():
267+
yield event
268+
finally:
269+
watcher.close()
270+
if server:
271+
server.close()
272+
if fixed_cid:
273+
self.device.l2cap_channel_manager.deregister_fixed_channel(fixed_cid)
274+
275+
@utils.rpc
276+
async def Disconnect(
277+
self, request: l2cap_pb2.DisconnectRequest, context: grpc.ServicerContext
278+
) -> l2cap_pb2.DisconnectResponse:
279+
self.log.info('Disconnect')
280+
channel = self.get_channel(request.channel)
281+
if isinstance(channel, FixedChannelProxy):
282+
raise ValueError('Fixed channel cannot be disconnected')
283+
284+
assert isinstance(channel, CocChannelProxy)
285+
await channel.disconnect()
286+
return l2cap_pb2.DisconnectResponse(success=empty_pb2.Empty())
287+
288+
@utils.rpc
289+
async def WaitDisconnection(
290+
self, request: l2cap_pb2.WaitDisconnectionRequest, context: grpc.ServicerContext
291+
) -> l2cap_pb2.WaitDisconnectionResponse:
292+
self.log.info('WaitDisconnection')
293+
channel = self.get_channel(request.channel)
294+
if isinstance(channel, FixedChannelProxy):
295+
raise RuntimeError('Fixed channel cannot be disconnected')
296+
297+
assert isinstance(channel, CocChannelProxy)
298+
await channel.wait_disconnect()
299+
return l2cap_pb2.WaitDisconnectionResponse(success=empty_pb2.Empty())
300+
301+
@utils.rpc
302+
async def Receive(
303+
self, request: l2cap_pb2.ReceiveRequest, context: grpc.ServicerContext
304+
) -> AsyncGenerator[l2cap_pb2.ReceiveResponse, None]:
305+
self.log.info('Receive')
306+
channel = self.get_channel(request.channel)
307+
308+
while packet := await channel.receive():
309+
yield l2cap_pb2.ReceiveResponse(data=packet)
310+
311+
@utils.rpc
312+
async def Send(
313+
self, request: l2cap_pb2.SendRequest, context: grpc.ServicerContext
314+
) -> l2cap_pb2.SendResponse:
315+
self.log.info('Send')
316+
channel = self.get_channel(request.channel)
317+
channel.send(request.data)
318+
return l2cap_pb2.SendResponse(success=empty_pb2.Empty())

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ include_package_data = True
3333
install_requires =
3434
aiohttp ~= 3.8; platform_system!='Emscripten'
3535
appdirs >= 1.4; platform_system!='Emscripten'
36-
bt-test-interfaces >= 0.0.2; platform_system!='Emscripten'
36+
bt-test-interfaces >= 0.0.4; platform_system!='Emscripten'
3737
click == 8.1.3; platform_system!='Emscripten'
3838
cryptography == 39; platform_system!='Emscripten'
3939
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the

0 commit comments

Comments
 (0)