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