• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2021-2022 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# -----------------------------------------------------------------------------
16# Imports
17# -----------------------------------------------------------------------------
18import asyncio
19import logging
20import os
21import click
22
23from bumble.colors import color
24from bumble.transport import open_transport_or_link
25from bumble.device import Device
26from bumble.utils import FlowControlAsyncPipe
27from bumble.hci import HCI_Constant
28
29
30# -----------------------------------------------------------------------------
31class ServerBridge:
32    """
33    L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
34    on a specified PSM. When the connection is made, the bridge connects a TCP
35    socket to a remote host and bridges the data in both directions, with flow
36    control.
37    When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
38    and waits for a new L2CAP CoC channel to be connected.
39    When the TCP connection is closed by the TCP server, XXXX
40    """
41
42    def __init__(self, psm, max_credits, mtu, mps, tcp_host, tcp_port):
43        self.psm = psm
44        self.max_credits = max_credits
45        self.mtu = mtu
46        self.mps = mps
47        self.tcp_host = tcp_host
48        self.tcp_port = tcp_port
49
50    async def start(self, device):
51        # Listen for incoming L2CAP CoC connections
52        device.register_l2cap_channel_server(
53            psm=self.psm,
54            server=self.on_coc,
55            max_credits=self.max_credits,
56            mtu=self.mtu,
57            mps=self.mps,
58        )
59        print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
60
61        def on_ble_connection(connection):
62            def on_ble_disconnection(reason):
63                print(
64                    color('@@@ Bluetooth disconnection:', 'red'),
65                    HCI_Constant.error_name(reason),
66                )
67
68            print(color('@@@ Bluetooth connection:', 'green'), connection)
69            connection.on('disconnection', on_ble_disconnection)
70
71        device.on('connection', on_ble_connection)
72
73        await device.start_advertising(auto_restart=True)
74
75    # Called when a new L2CAP connection is established
76    def on_coc(self, l2cap_channel):
77        print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
78
79        class Pipe:
80            def __init__(self, bridge, l2cap_channel):
81                self.bridge = bridge
82                self.tcp_transport = None
83                self.l2cap_channel = l2cap_channel
84
85                l2cap_channel.on('close', self.on_l2cap_close)
86                l2cap_channel.sink = self.on_coc_sdu
87
88            async def connect_to_tcp(self):
89                # Connect to the TCP server
90                print(
91                    color(
92                        f'### Connecting to TCP {self.bridge.tcp_host}:'
93                        f'{self.bridge.tcp_port}...',
94                        'yellow',
95                    )
96                )
97
98                class TcpClientProtocol(asyncio.Protocol):
99                    def __init__(self, pipe):
100                        self.pipe = pipe
101
102                    def connection_lost(self, exc):
103                        print(color(f'!!! TCP connection lost: {exc}', 'red'))
104                        if self.pipe.l2cap_channel is not None:
105                            asyncio.create_task(self.pipe.l2cap_channel.disconnect())
106
107                    def data_received(self, data):
108                        print(f'<<< Received on TCP: {len(data)}')
109                        self.pipe.l2cap_channel.write(data)
110
111                try:
112                    (
113                        self.tcp_transport,
114                        _,
115                    ) = await asyncio.get_running_loop().create_connection(
116                        lambda: TcpClientProtocol(self),
117                        host=self.bridge.tcp_host,
118                        port=self.bridge.tcp_port,
119                    )
120                    print(color('### Connected', 'green'))
121                except Exception as error:
122                    print(color(f'!!! Connection failed: {error}', 'red'))
123                    await self.l2cap_channel.disconnect()
124
125            def on_l2cap_close(self):
126                self.l2cap_channel = None
127                if self.tcp_transport is not None:
128                    self.tcp_transport.close()
129
130            def on_coc_sdu(self, sdu):
131                print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
132                if self.tcp_transport is None:
133                    print(color('!!! TCP socket not open, dropping', 'red'))
134                    return
135                self.tcp_transport.write(sdu)
136
137        pipe = Pipe(self, l2cap_channel)
138
139        asyncio.create_task(pipe.connect_to_tcp())
140
141
142# -----------------------------------------------------------------------------
143class ClientBridge:
144    """
145    L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
146    TCP connection on a specified port number. When a TCP client connects, an
147    L2CAP CoC channel connection to the BLE device is established, and the data
148    is bridged in both directions, with flow control.
149    When the TCP connection is closed by the client, the L2CAP CoC channel is
150    disconnected, but the connection to the BLE device remains, ready for a new
151    TCP client to connect.
152    When the L2CAP CoC channel is closed, XXXX
153    """
154
155    READ_CHUNK_SIZE = 4096
156
157    def __init__(self, psm, max_credits, mtu, mps, address, tcp_host, tcp_port):
158        self.psm = psm
159        self.max_credits = max_credits
160        self.mtu = mtu
161        self.mps = mps
162        self.address = address
163        self.tcp_host = tcp_host
164        self.tcp_port = tcp_port
165
166    async def start(self, device):
167        print(color(f'### Connecting to {self.address}...', 'yellow'))
168        connection = await device.connect(self.address)
169        print(color('### Connected', 'green'))
170
171        # Called when the BLE connection is disconnected
172        def on_ble_disconnection(reason):
173            print(
174                color('@@@ Bluetooth disconnection:', 'red'),
175                HCI_Constant.error_name(reason),
176            )
177
178        connection.on('disconnection', on_ble_disconnection)
179
180        # Called when a TCP connection is established
181        async def on_tcp_connection(reader, writer):
182            peer_name = writer.get_extra_info('peer_name')
183            print(color(f'<<< TCP connection from {peer_name}', 'magenta'))
184
185            def on_coc_sdu(sdu):
186                print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
187                l2cap_to_tcp_pipe.write(sdu)
188
189            def on_l2cap_close():
190                print(color('*** L2CAP channel closed', 'red'))
191                l2cap_to_tcp_pipe.stop()
192                writer.close()
193
194            # Connect a new L2CAP channel
195            print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
196            try:
197                l2cap_channel = await connection.open_l2cap_channel(
198                    psm=self.psm,
199                    max_credits=self.max_credits,
200                    mtu=self.mtu,
201                    mps=self.mps,
202                )
203                print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
204            except Exception as error:
205                print(color(f'!!! Connection failed: {error}', 'red'))
206                writer.close()
207                return
208
209            l2cap_channel.sink = on_coc_sdu
210            l2cap_channel.on('close', on_l2cap_close)
211
212            # Start a flow control pipe from L2CAP to TCP
213            l2cap_to_tcp_pipe = FlowControlAsyncPipe(
214                l2cap_channel.pause_reading,
215                l2cap_channel.resume_reading,
216                writer.write,
217                writer.drain,
218            )
219            l2cap_to_tcp_pipe.start()
220
221            # Pipe data from TCP to L2CAP
222            while True:
223                try:
224                    data = await reader.read(self.READ_CHUNK_SIZE)
225
226                    if len(data) == 0:
227                        print(color('!!! End of stream', 'red'))
228                        await l2cap_channel.disconnect()
229                        return
230
231                    print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
232                    l2cap_channel.write(data)
233                    await l2cap_channel.drain()
234                except Exception as error:
235                    print(f'!!! Exception: {error}')
236                    break
237
238            writer.close()
239            print(color('~~~ Bye bye', 'magenta'))
240
241        await asyncio.start_server(
242            on_tcp_connection,
243            host=self.tcp_host if self.tcp_host != '_' else None,
244            port=self.tcp_port,
245        )
246        print(
247            color(
248                f'### Listening for TCP connections on port {self.tcp_port}', 'magenta'
249            )
250        )
251
252
253# -----------------------------------------------------------------------------
254async def run(device_config, hci_transport, bridge):
255    print('<<< connecting to HCI...')
256    async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
257        print('<<< connected')
258
259        device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
260
261        # Let's go
262        await device.power_on()
263        await bridge.start(device)
264
265        # Wait until the transport terminates
266        await hci_source.wait_for_termination()
267
268
269# -----------------------------------------------------------------------------
270@click.group()
271@click.pass_context
272@click.option('--device-config', help='Device configuration file', required=True)
273@click.option('--hci-transport', help='HCI transport', required=True)
274@click.option('--psm', help='PSM for L2CAP CoC', type=int, default=1234)
275@click.option(
276    '--l2cap-coc-max-credits',
277    help='Maximum L2CAP CoC Credits',
278    type=click.IntRange(1, 65535),
279    default=128,
280)
281@click.option(
282    '--l2cap-coc-mtu',
283    help='L2CAP CoC MTU',
284    type=click.IntRange(23, 65535),
285    default=1022,
286)
287@click.option(
288    '--l2cap-coc-mps',
289    help='L2CAP CoC MPS',
290    type=click.IntRange(23, 65533),
291    default=1024,
292)
293def cli(
294    context,
295    device_config,
296    hci_transport,
297    psm,
298    l2cap_coc_max_credits,
299    l2cap_coc_mtu,
300    l2cap_coc_mps,
301):
302    context.ensure_object(dict)
303    context.obj['device_config'] = device_config
304    context.obj['hci_transport'] = hci_transport
305    context.obj['psm'] = psm
306    context.obj['max_credits'] = l2cap_coc_max_credits
307    context.obj['mtu'] = l2cap_coc_mtu
308    context.obj['mps'] = l2cap_coc_mps
309
310
311# -----------------------------------------------------------------------------
312@cli.command()
313@click.pass_context
314@click.option('--tcp-host', help='TCP host', default='localhost')
315@click.option('--tcp-port', help='TCP port', default=9544)
316def server(context, tcp_host, tcp_port):
317    bridge = ServerBridge(
318        context.obj['psm'],
319        context.obj['max_credits'],
320        context.obj['mtu'],
321        context.obj['mps'],
322        tcp_host,
323        tcp_port,
324    )
325    asyncio.run(run(context.obj['device_config'], context.obj['hci_transport'], bridge))
326
327
328# -----------------------------------------------------------------------------
329@cli.command()
330@click.pass_context
331@click.argument('bluetooth-address')
332@click.option('--tcp-host', help='TCP host', default='_')
333@click.option('--tcp-port', help='TCP port', default=9543)
334def client(context, bluetooth_address, tcp_host, tcp_port):
335    bridge = ClientBridge(
336        context.obj['psm'],
337        context.obj['max_credits'],
338        context.obj['mtu'],
339        context.obj['mps'],
340        bluetooth_address,
341        tcp_host,
342        tcp_port,
343    )
344    asyncio.run(run(context.obj['device_config'], context.obj['hci_transport'], bridge))
345
346
347# -----------------------------------------------------------------------------
348logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
349if __name__ == '__main__':
350    cli(obj={})  # pylint: disable=no-value-for-parameter
351