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"""Host grpc interface.""" 15 16import asyncio 17import logging 18from typing import AsyncGenerator 19import uuid as uuid_module 20 21from floss.pandora.floss import adapter_client 22from floss.pandora.floss import advertising_client 23from floss.pandora.floss import floss_enums 24from floss.pandora.floss import gatt_client 25from floss.pandora.floss import scanner_client 26from floss.pandora.floss import utils 27from floss.pandora.server import bluetooth as bluetooth_module 28from google.protobuf import empty_pb2 29import grpc 30from pandora import host_grpc_aio 31from pandora import host_pb2 32from pandora import security_grpc_aio 33 34 35class HostService(host_grpc_aio.HostServicer): 36 """Service to trigger Bluetooth Host procedures. 37 38 This class implements the Pandora bluetooth test interfaces, 39 where the meta class definition is automatically generated by the protobuf. 40 The interface definition can be found in: 41 https://cs.android.com/android/platform/superproject/+/main:external 42 /pandora/bt-test-interfaces/pandora/host.proto 43 """ 44 45 def __init__(self, server: grpc.aio.Server, bluetooth: bluetooth_module.Bluetooth, 46 security: security_grpc_aio.SecurityServicer): 47 self.server = server 48 self.bluetooth = bluetooth 49 self.security = security 50 self.waited_connections = set() 51 self.initiated_le_connection = set() 52 53 async def FactoryReset(self, request: empty_pb2.Empty, context: grpc.ServicerContext) -> empty_pb2.Empty: 54 self.waited_connections.clear() 55 self.initiated_le_connection.clear() 56 57 devices = self.bluetooth.get_bonded_devices() 58 if devices is None: 59 logging.error('Failed to call get_bonded_devices.') 60 else: 61 for device in devices: 62 address = device['address'] 63 logging.info('Forget device %s', address) 64 self.bluetooth.forget_device(address) 65 66 asyncio.create_task(self.server.stop(None)) 67 return empty_pb2.Empty() 68 69 async def Reset(self, request: empty_pb2.Empty, context: grpc.ServicerContext) -> empty_pb2.Empty: 70 self.waited_connections.clear() 71 self.initiated_le_connection.clear() 72 self.bluetooth.reset() 73 return empty_pb2.Empty() 74 75 async def ReadLocalAddress(self, request: empty_pb2.Empty, 76 context: grpc.ServicerContext) -> host_pb2.ReadLocalAddressResponse: 77 address = self.bluetooth.get_address() 78 return host_pb2.ReadLocalAddressResponse(address=utils.address_to(address)) 79 80 async def Connect(self, request: host_pb2.ConnectRequest, 81 context: grpc.ServicerContext) -> host_pb2.ConnectResponse: 82 83 class ConnectionObserver(adapter_client.BluetoothConnectionCallbacks): 84 """Observer to observe the connection state.""" 85 86 def __init__(self, client: adapter_client, task): 87 self.client = client 88 self.task = task 89 90 @utils.glib_callback() 91 def on_device_connected(self, remote_device): 92 address, _ = remote_device 93 if address != self.task['address']: 94 return 95 96 if self.client.is_bonded(address): 97 future = self.task['connect_device'] 98 future.get_loop().call_soon_threadsafe(future.set_result, (True, None)) 99 100 class PairingObserver(adapter_client.BluetoothCallbacks): 101 """Observer to observe the bond state.""" 102 103 def __init__(self, client: adapter_client, security: security_grpc_aio.SecurityServicer, task): 104 self.client = client 105 self.security = security 106 self.task = task 107 108 @utils.glib_callback() 109 def on_bond_state_changed(self, status, address, state): 110 if address != self.task['address']: 111 return 112 113 if status != 0: 114 future = self.task['create_bond'] 115 future.get_loop().call_soon_threadsafe( 116 future.set_result, (False, f'{address} failed to bond. Status: {status}, State: {state}')) 117 return 118 119 if state == floss_enums.BondState.BONDED: 120 if not self.client.is_connected(self.task['address']): 121 logging.info('%s calling connect_all_enabled_profiles', address) 122 if not self.client.connect_all_enabled_profiles(self.task['address']): 123 future = self.task['create_bond'] 124 future.get_loop().call_soon_threadsafe( 125 future.set_result, 126 (False, f'{self.task["address"]} failed on connect_all_enabled_profiles')) 127 else: 128 future = self.task['create_bond'] 129 future.get_loop().call_soon_threadsafe(future.set_result, (True, None)) 130 131 @utils.glib_callback() 132 def on_ssp_request(self, remote_device, class_of_device, variant, passkey): 133 address, _ = remote_device 134 if address != self.task['address']: 135 return 136 137 if variant in (floss_enums.PairingVariant.CONSENT, floss_enums.PairingVariant.PASSKEY_CONFIRMATION): 138 self.client.set_pairing_confirmation(address, 139 True, 140 method_callback=self.on_set_pairing_confirmation) 141 142 @utils.glib_callback() 143 def on_set_pairing_confirmation(self, err, result): 144 if err or not result: 145 future = self.task['create_bond'] 146 future.get_loop().call_soon_threadsafe( 147 future.set_result, (False, f'Pairing confirmation failed: err: {err}, result: {result}')) 148 149 address = utils.address_from(request.address) 150 151 if not self.bluetooth.is_connected(address): 152 name = None 153 observer = None 154 try: 155 if self.bluetooth.is_bonded(address): 156 connect_device = asyncio.get_running_loop().create_future() 157 observer = ConnectionObserver(self.bluetooth.adapter_client, { 158 'connect_device': connect_device, 159 'address': address 160 }) 161 name = utils.create_observer_name(observer) 162 self.bluetooth.adapter_client.register_callback_observer(name, observer) 163 164 self.bluetooth.connect_device(address) 165 success, reason = await connect_device 166 167 if not success: 168 await context.abort(grpc.StatusCode.UNKNOWN, 169 f'Failed to connect to the {address}. Reason: {reason}.') 170 else: 171 if not self.security.manually_confirm: 172 create_bond = asyncio.get_running_loop().create_future() 173 observer = PairingObserver( 174 self.bluetooth.adapter_client, 175 self.security, 176 { 177 'create_bond': create_bond, 178 'address': address 179 }, 180 ) 181 name = utils.create_observer_name(observer) 182 self.bluetooth.adapter_client.register_callback_observer(name, observer) 183 184 if not self.bluetooth.create_bond(address, floss_enums.BtTransport.BREDR): 185 await context.abort(grpc.StatusCode.UNKNOWN, 'Failed to call create_bond.') 186 187 if not self.security.manually_confirm: 188 success, reason = await create_bond 189 190 if not success: 191 await context.abort(grpc.StatusCode.UNKNOWN, 192 f'Failed to connect to the {address}. Reason: {reason}.') 193 194 if self.bluetooth.is_bonded(address) and self.bluetooth.is_connected(address): 195 self.bluetooth.connect_device(address) 196 finally: 197 self.bluetooth.adapter_client.unregister_callback_observer(name, observer) 198 199 return host_pb2.ConnectResponse( 200 connection=utils.connection_to(utils.Connection(address, floss_enums.BtTransport.BREDR))) 201 202 async def WaitConnection(self, request: host_pb2.WaitConnectionRequest, 203 context: grpc.ServicerContext) -> host_pb2.WaitConnectionResponse: 204 205 class ConnectionObserver(adapter_client.BluetoothConnectionCallbacks): 206 """Observer to observe the connection state.""" 207 208 def __init__(self, task): 209 self.task = task 210 211 @utils.glib_callback() 212 def on_device_connected(self, remote_device): 213 address, _ = remote_device 214 if address != self.task['address']: 215 return 216 217 future = self.task['wait_connection'] 218 future.get_loop().call_soon_threadsafe(future.set_result, address) 219 220 if not request.address: 221 await context.abort(grpc.StatusCode.INVALID_ARGUMENT, 'Request address field must be set.') 222 223 address = utils.address_from(request.address) 224 225 if not self.bluetooth.is_connected(address) or address not in self.waited_connections: 226 try: 227 wait_connection = asyncio.get_running_loop().create_future() 228 observer = ConnectionObserver({'wait_connection': wait_connection, 'address': address}) 229 name = utils.create_observer_name(observer) 230 self.bluetooth.adapter_client.register_callback_observer(name, observer) 231 232 await wait_connection 233 finally: 234 self.bluetooth.adapter_client.unregister_callback_observer(name, observer) 235 self.waited_connections.add(address) 236 237 return host_pb2.WaitConnectionResponse( 238 connection=utils.connection_to(utils.Connection(address, floss_enums.BtTransport.BREDR))) 239 240 async def ConnectLE(self, request: host_pb2.ConnectLERequest, 241 context: grpc.ServicerContext) -> host_pb2.ConnectLEResponse: 242 243 class ConnectionObserver(gatt_client.GattClientCallbacks): 244 """Observer to observe the connection state.""" 245 246 def __init__(self, task): 247 self.task = task 248 249 @utils.glib_callback() 250 def on_client_connection_state(self, status, client_id, connected, addr): 251 if addr != self.task['address']: 252 return 253 254 future = self.task['connect_le_device'] 255 if status != floss_enums.GattStatus.SUCCESS: 256 future.get_loop().call_soon_threadsafe(future.set_result, 257 (False, f'{address} failed to connect. Status: {status}.')) 258 return 259 260 future.get_loop().call_soon_threadsafe(future.set_result, (connected, None)) 261 262 if not request.address: 263 await context.abort(grpc.StatusCode.INVALID_ARGUMENT, 'Request address field must be set.') 264 265 own_address_type = request.own_address_type 266 if own_address_type not in (host_pb2.RANDOM, host_pb2.RESOLVABLE_OR_RANDOM): 267 await context.abort(grpc.StatusCode.UNIMPLEMENTED, f'Unsupported OwnAddressType: {own_address_type}.') 268 269 address = utils.address_from(request.address) 270 self.initiated_le_connection.add(address) 271 try: 272 connect_le_device = asyncio.get_running_loop().create_future() 273 observer = ConnectionObserver({'connect_le_device': connect_le_device, 'address': address}) 274 name = utils.create_observer_name(observer) 275 self.bluetooth.gatt_client.register_callback_observer(name, observer) 276 self.bluetooth.gatt_connect(address, True, floss_enums.BtTransport.LE) 277 connected, reason = await connect_le_device 278 if not connected: 279 await context.abort(grpc.StatusCode.UNKNOWN, 280 f'Failed to connect to the address: {address}. Reason: {reason}.') 281 finally: 282 self.bluetooth.gatt_client.unregister_callback_observer(name, observer) 283 284 return host_pb2.ConnectLEResponse( 285 connection=utils.connection_to(utils.Connection(address, floss_enums.BtTransport.LE))) 286 287 async def Disconnect(self, request: host_pb2.DisconnectRequest, context: grpc.ServicerContext) -> empty_pb2.Empty: 288 address = utils.connection_from(request.connection).address 289 290 if self.bluetooth.is_connected(address): 291 self.bluetooth.disconnect_device(address) 292 return empty_pb2.Empty() 293 294 async def WaitDisconnection(self, request: host_pb2.WaitDisconnectionRequest, 295 context: grpc.ServicerContext) -> empty_pb2.Empty: 296 297 class ConnectionObserver(adapter_client.BluetoothConnectionCallbacks): 298 """Observer to observe the connection state.""" 299 300 def __init__(self, task): 301 self.task = task 302 303 @utils.glib_callback() 304 def on_device_disconnected(self, remote_device): 305 address, _ = remote_device 306 if address != self.task['address']: 307 return 308 309 future = self.task['wait_disconnection'] 310 future.get_loop().call_soon_threadsafe(future.set_result, address) 311 312 if not request.address: 313 await context.abort(grpc.StatusCode.INVALID_ARGUMENT, 'Request address field must be set.') 314 315 address = utils.address_from(request.address) 316 317 if self.bluetooth.is_connected(address): 318 try: 319 wait_disconnection = asyncio.get_running_loop().create_future() 320 observer = ConnectionObserver({'wait_disconnection': wait_disconnection, 'address': address}) 321 name = utils.create_observer_name(observer) 322 self.bluetooth.adapter_client.register_callback_observer(name, observer) 323 await wait_disconnection 324 finally: 325 self.bluetooth.adapter_client.unregister_callback_observer(name, observer) 326 327 return empty_pb2.Empty() 328 329 async def Advertise(self, request: host_pb2.AdvertiseRequest, 330 context: grpc.ServicerContext) -> AsyncGenerator[host_pb2.AdvertiseResponse, None]: 331 parameters = { 332 'connectable': request.connectable, 333 'scannable': True, 334 'is_legacy': True, # ROOTCANAL: Extended advertising ignored because the scanner is legacy. 335 'is_anonymous': False, 336 'include_tx_power': True, 337 'primary_phy': 1, 338 'secondary_phy': 1, 339 'interval': request.interval, 340 'tx_power_level': 127, # 0x7f 341 'own_address_type': -1, # default 342 } 343 344 primary_phy = request.primary_phy 345 if primary_phy == host_pb2.PRIMARY_1M: 346 parameters['primary_phy'] = floss_enums.LePhy.PHY1M 347 elif primary_phy == host_pb2.PRIMARY_CODED: 348 parameters['primary_phy'] = floss_enums.LePhy.PHY_CODED 349 350 secondary_phy = request.secondary_phy 351 if secondary_phy == host_pb2.SECONDARY_NONE: 352 parameters['secondary_phy'] = floss_enums.LePhy.INVALID 353 elif secondary_phy == host_pb2.SECONDARY_1M: 354 parameters['secondary_phy'] = floss_enums.LePhy.PHY1M 355 elif secondary_phy == host_pb2.SECONDARY_2M: 356 parameters['secondary_phy'] = floss_enums.LePhy.PHY2M 357 elif secondary_phy == host_pb2.SECONDARY_CODED: 358 parameters['secondary_phy'] = floss_enums.LePhy.PHY_CODED 359 360 own_address_type = request.own_address_type 361 if own_address_type in (host_pb2.PUBLIC, host_pb2.RESOLVABLE_OR_PUBLIC): 362 parameters['own_address_type'] = floss_enums.OwnAddressType.PUBLIC 363 elif own_address_type in (host_pb2.RANDOM, host_pb2.RESOLVABLE_OR_RANDOM): 364 parameters['own_address_type'] = floss_enums.OwnAddressType.RANDOM 365 366 # TODO: b/289480188 - Support more data and scan response data if needed. 367 advertise_data = utils.advertise_data_from(request.data) 368 369 class AdvertisingObserver(advertising_client.BluetoothAdvertisingCallbacks): 370 """Observer to observe the advertising state.""" 371 372 def __init__(self, task): 373 self.task = task 374 375 @utils.glib_callback() 376 def on_advertising_set_started(self, reg_id, advertiser_id, tx_power, status): 377 if reg_id != self.task['reg_id']: 378 return 379 380 if status is None or floss_enums.GattStatus(status) != floss_enums.GattStatus.SUCCESS: 381 logging.error('Failed to start advertising.') 382 advertiser_id = None 383 384 future = self.task['start_advertising'] 385 future.get_loop().call_soon_threadsafe(future.set_result, advertiser_id) 386 387 class ConnectionObserver(adapter_client.BluetoothConnectionCallbacks): 388 """Observer to observe all connections.""" 389 390 def __init__(self, loop: asyncio.AbstractEventLoop, task): 391 self.loop = loop 392 self.task = task 393 394 @utils.glib_callback() 395 def on_device_connected(self, remote_device): 396 address, _ = remote_device 397 asyncio.run_coroutine_threadsafe(self.task['connections'].put(address), self.loop) 398 399 started_ids = [] 400 observers = [] 401 try: 402 if request.connectable: 403 connections = asyncio.Queue() 404 observer = ConnectionObserver(asyncio.get_running_loop(), {'connections': connections}) 405 name = utils.create_observer_name(observer) 406 self.bluetooth.adapter_client.register_callback_observer(name, observer) 407 observers.append((name, observer)) 408 409 reg_id = self.bluetooth.start_advertising_set(parameters, advertise_data, None, None, None, 0, 0) 410 411 advertising_request = {'start_advertising': asyncio.get_running_loop().create_future(), 'reg_id': reg_id} 412 observer = AdvertisingObserver(advertising_request) 413 name = utils.create_observer_name(observer) 414 self.bluetooth.advertising_client.register_callback_observer(name, observer) 415 observers.append((name, observer)) 416 417 advertiser_id = await asyncio.wait_for(advertising_request['start_advertising'], timeout=5) 418 if advertiser_id is None: 419 await context.abort(grpc.StatusCode.UNKNOWN, 'Failed to start advertising.') 420 421 started_ids.append(advertiser_id) 422 423 while True: 424 if not request.connectable: 425 await asyncio.sleep(1) 426 continue 427 428 logging.info('Advertise: Wait for LE connection...') 429 address = await connections.get() 430 logging.info('Advertise: Connected to %s', address) 431 432 yield host_pb2.AdvertiseResponse( 433 connection=utils.connection_to(utils.Connection(address, floss_enums.BtTransport.LE))) 434 435 await asyncio.sleep(1) 436 finally: 437 for name, observer in observers: 438 self.bluetooth.adapter_client.unregister_callback_observer(name, observer) 439 self.bluetooth.advertising_client.unregister_callback_observer(name, observer) 440 441 for started in started_ids: 442 self.bluetooth.stop_advertising_set(started) 443 444 async def Scan(self, request: host_pb2.ScanRequest, 445 context: grpc.ServicerContext) -> AsyncGenerator[host_pb2.ScanningResponse, None]: 446 447 class ScanObserver(scanner_client.BluetoothScannerCallbacks): 448 """Observer to observer the scan state and scan results.""" 449 450 def __init__(self, loop: asyncio.AbstractEventLoop, task): 451 self.loop = loop 452 self.task = task 453 454 @utils.glib_callback() 455 def on_scanner_registered(self, uuid, scanner_id, status): 456 uuid = uuid_module.UUID(bytes=bytes(uuid)) 457 if uuid != self.task['uuid']: 458 return 459 460 if floss_enums.GattStatus(status) != floss_enums.GattStatus.SUCCESS: 461 logging.error('Failed to register scanner! uuid: {uuid}') 462 scanner_id = None 463 464 future = self.task['register_scanner'] 465 future.get_loop().call_soon_threadsafe(future.set_result, scanner_id) 466 467 @utils.glib_callback() 468 def on_scan_result(self, scan_result): 469 asyncio.run_coroutine_threadsafe(self.task['scan_results'].put(scan_result), self.loop) 470 471 scanner_id = None 472 name = None 473 observer = None 474 try: 475 uuid = self.bluetooth.register_scanner() 476 scan = { 477 'register_scanner': asyncio.get_running_loop().create_future(), 478 'uuid': uuid, 479 'scan_results': asyncio.Queue() 480 } 481 observer = ScanObserver(asyncio.get_running_loop(), scan) 482 name = utils.create_observer_name(observer) 483 self.bluetooth.scanner_client.register_callback_observer(name, observer) 484 485 scanner_id = await asyncio.wait_for(scan['register_scanner'], timeout=10) 486 487 self.bluetooth.start_scan(scanner_id) 488 while True: 489 scan_result = await scan['scan_results'].get() 490 491 response = host_pb2.ScanningResponse() 492 response.tx_power = scan_result['tx_power'] 493 response.rssi = scan_result['rssi'] 494 response.sid = scan_result['advertising_sid'] 495 response.periodic_advertising_interval = scan_result['periodic_adv_int'] 496 497 if scan_result['primary_phy'] == floss_enums.LePhy.PHY1M: 498 response.primary_phy = host_pb2.PRIMARY_1M 499 elif scan_result['primary_phy'] == floss_enums.LePhy.PHY_CODED: 500 response.primary_phy = host_pb2.PRIMARY_CODED 501 else: 502 pass 503 504 if scan_result['secondary_phy'] == floss_enums.LePhy.INVALID: 505 response.secondary_phy = host_pb2.SECONDARY_NONE 506 elif scan_result['secondary_phy'] == floss_enums.LePhy.PHY1M: 507 response.secondary_phy = host_pb2.SECONDARY_1M 508 elif scan_result['secondary_phy'] == floss_enums.LePhy.PHY2M: 509 response.secondary_phy = host_pb2.SECONDARY_2M 510 elif scan_result['secondary_phy'] == floss_enums.LePhy.PHY_CODED: 511 response.secondary_phy = host_pb2.SECONDARY_CODED 512 513 address = bytes.fromhex(scan_result['address'].replace(':', '')) 514 if scan_result['addr_type'] == floss_enums.BleAddressType.BLE_ADDR_PUBLIC: 515 response.public = address 516 elif scan_result['addr_type'] == floss_enums.BleAddressType.BLE_ADDR_RANDOM: 517 response.random = address 518 elif scan_result['addr_type'] == floss_enums.BleAddressType.BLE_ADDR_PUBLIC_ID: 519 response.public_identity = address 520 elif scan_result['addr_type'] == floss_enums.BleAddressType.BLE_ADDR_RANDOM_ID: 521 response.random_static_identity = address 522 523 data = utils.parse_advertiging_data(scan_result['adv_data']) 524 response.data.CopyFrom(data) 525 526 # TODO: b/289480188 - Support more data if needed. 527 mode = host_pb2.NOT_DISCOVERABLE 528 if scan_result['flags'] & (1 << 0): 529 mode = host_pb2.DISCOVERABLE_LIMITED 530 elif scan_result['flags'] & (1 << 1): 531 mode = host_pb2.DISCOVERABLE_GENERAL 532 else: 533 mode = host_pb2.NOT_DISCOVERABLE 534 response.data.le_discoverability_mode = mode 535 536 yield response 537 finally: 538 if scanner_id is not None: 539 self.bluetooth.stop_scan(scanner_id) 540 if name is not None and observer is not None: 541 self.bluetooth.scanner_client.unregister_callback_observer(name, observer) 542 543 async def Inquiry(self, request: empty_pb2.Empty, 544 context: grpc.ServicerContext) -> AsyncGenerator[host_pb2.InquiryResponse, None]: 545 546 class InquiryResultObserver(adapter_client.BluetoothCallbacks): 547 """Observer to observe all inquiry results.""" 548 549 def __init__(self, loop: asyncio.AbstractEventLoop, task): 550 self.loop = loop 551 self.task = task 552 553 @utils.glib_callback() 554 def on_device_found(self, remote_device): 555 address, _ = remote_device 556 asyncio.run_coroutine_threadsafe(self.task['inquiry_results'].put(address), self.loop) 557 558 class DiscoveryObserver(adapter_client.BluetoothCallbacks): 559 """Observer to observe discovery state.""" 560 561 def __init__(self, task): 562 self.task = task 563 564 @utils.glib_callback() 565 def on_discovering_changed(self, discovering): 566 if discovering == self.task['discovering']: 567 future = self.task['start_inquiry'] 568 future.get_loop().call_soon_threadsafe(future.set_result, discovering) 569 570 observers = [] 571 try: 572 if not self.bluetooth.is_discovering(): 573 inquriy = {'start_inquiry': asyncio.get_running_loop().create_future(), 'discovering': True} 574 observer = DiscoveryObserver(inquriy) 575 name = utils.create_observer_name(observer) 576 self.bluetooth.adapter_client.register_callback_observer(name, observer) 577 observers.append((name, observer)) 578 579 self.bluetooth.start_discovery() 580 await asyncio.wait_for(inquriy['start_inquiry'], timeout=10) 581 582 inquiry_results = asyncio.Queue() 583 observer = InquiryResultObserver(asyncio.get_running_loop(), {'inquiry_results': inquiry_results}) 584 name = utils.create_observer_name(observer) 585 self.bluetooth.adapter_client.register_callback_observer(name, observer) 586 observers.append((name, observer)) 587 588 while True: 589 address = await inquiry_results.get() 590 yield host_pb2.InquiryResponse(address=utils.address_to(address)) 591 finally: 592 self.bluetooth.stop_discovery() 593 for name, observer in observers: 594 self.bluetooth.adapter_client.unregister_callback_observer(name, observer) 595 596 async def SetDiscoverabilityMode(self, request: host_pb2.SetDiscoverabilityModeRequest, 597 context: grpc.ServicerContext) -> empty_pb2.Empty: 598 mode = request.mode 599 duration = 600 # Sets general, limited default to 60s. This is unused by the non-discoverable mode. 600 self.bluetooth.set_discoverable(mode, duration) 601 return empty_pb2.Empty() 602 603 async def SetConnectabilityMode(self, request: host_pb2.SetConnectabilityModeRequest, 604 context: grpc.ServicerContext) -> empty_pb2.Empty: 605 mode = request.mode 606 self.bluetooth.set_connectable(mode) 607 return empty_pb2.Empty() 608