diff options
author | uael <uael@google.com> | 2023-04-20 01:00:34 +0000 |
---|---|---|
committer | uael <uael@google.com> | 2023-05-05 04:57:11 +0000 |
commit | d4fbff7cb6223a14f88afbbe93fb4a87ea80f0aa (patch) | |
tree | cf0605878992a429226aed6eb6a36edf69b8136b /avatar | |
parent | cbdefa9432d59b89efd67ba25683b93e776ac44a (diff) | |
download | avatar-d4fbff7cb6223a14f88afbbe93fb4a87ea80f0aa.tar.gz |
overall: update to latest bumble
This commit includes:
* Remove the need to override `__repr__` for enums passed through
`avatar.parameterized`.
* Remove the set of `device.classic_enabled` which has been implemented
into bumble.
* Fixed `set_discoverable(False)` in the bumble server which should be
true.
* Normalize string format of UUID.
* Add a server configuration class which is shared between a client and
it's server. This class is now used to pass all pairing confirguations
variables which make pairing configuration a lot easier.
* Use mobly asserts instead of `assert` for better error messages, keep
`assert` where necessary for the linter to be happy.
Change-Id: I6b4a62127f30fc7179c7b8886186c2ba0fa880fe
Diffstat (limited to 'avatar')
-rw-r--r-- | avatar/__init__.py | 14 | ||||
-rw-r--r-- | avatar/bumble_device.py | 2 | ||||
-rw-r--r-- | avatar/bumble_server/__init__.py | 39 | ||||
-rw-r--r-- | avatar/bumble_server/config.py | 42 | ||||
-rw-r--r-- | avatar/bumble_server/host.py | 33 | ||||
-rw-r--r-- | avatar/bumble_server/security.py | 48 | ||||
-rw-r--r-- | avatar/pandora_client.py | 9 | ||||
-rw-r--r-- | avatar/pandora_server.py | 11 |
8 files changed, 136 insertions, 62 deletions
diff --git a/avatar/__init__.py b/avatar/__init__.py index 8050261..a533502 100644 --- a/avatar/__init__.py +++ b/avatar/__init__.py @@ -19,6 +19,7 @@ any Bluetooth test cases virtually and physically. __version__ = "0.0.1" +import enum import functools import grpc import grpc.aio @@ -30,7 +31,7 @@ from avatar.aio import asynchronous from avatar.pandora_client import BumblePandoraClient as BumblePandoraDevice, PandoraClient as PandoraDevice from avatar.pandora_server import PandoraServer from mobly import base_test -from typing import Any, Callable, Dict, Iterable, Iterator, List, Sized, Tuple, Type, TypeVar +from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Sized, Tuple, Type, TypeVar # public symbols __all__ = [ @@ -174,9 +175,18 @@ def parameterized(*inputs: Tuple[Any, ...]) -> Type[Wrapper]: return wrapper + def normalize(a: Any) -> Any: + if isinstance(a, enum.Enum): + return a.value + return a + # we need to pass `input` here, otherwise it will be set to the value # from the last iteration of `inputs` - setattr(owner, f"{name}{input}".replace(' ', ''), decorate(input)) + setattr( + owner, + f"{name}{tuple([normalize(a) for a in input])}".replace(" ", ""), + decorate(input), + ) delattr(owner, name) return wrapper diff --git a/avatar/bumble_device.py b/avatar/bumble_device.py index 872e013..f688e48 100644 --- a/avatar/bumble_device.py +++ b/avatar/bumble_device.py @@ -98,8 +98,6 @@ def _make_device(config: Dict[str, Any]) -> Device: device_config.load_from_dict(config) device = Device(config=device_config, host=None) - # FIXME: add `classic_enabled` to `DeviceConfiguration` ? - device.classic_enabled = config.get('classic_enabled', False) # Add fake a2dp service to avoid Android disconnect device.sdp_service_records = _make_sdp_records(1) diff --git a/avatar/bumble_server/__init__.py b/avatar/bumble_server/__init__.py index 184968f..d0e0600 100644 --- a/avatar/bumble_server/__init__.py +++ b/avatar/bumble_server/__init__.py @@ -21,48 +21,57 @@ import grpc import grpc.aio import logging +from .config import Config from avatar.bumble_device import BumbleDevice from avatar.bumble_server.host import HostService from avatar.bumble_server.security import SecurityService, SecurityStorageService -from bumble.smp import PairingDelegate from pandora.host_grpc_aio import add_HostServicer_to_server from pandora.security_grpc_aio import add_SecurityServicer_to_server, add_SecurityStorageServicer_to_server from typing import Callable, List, Optional +# public symbols +__all__ = [ + 'register_servicer_hook', + 'serve', + 'Config', +] + + # Add servicers hooks. -_SERVICERS_HOOKS: List[Callable[[BumbleDevice, grpc.aio.Server], None]] = [] +_SERVICERS_HOOKS: List[Callable[[BumbleDevice, Config, grpc.aio.Server], None]] = [] -def register_servicer_hook(hook: Callable[[BumbleDevice, grpc.aio.Server], None]) -> None: +def register_servicer_hook(hook: Callable[[BumbleDevice, Config, grpc.aio.Server], None]) -> None: _SERVICERS_HOOKS.append(hook) -async def serve_bumble(bumble: BumbleDevice, grpc_server: Optional[grpc.aio.Server] = None, port: int = 0) -> None: +async def serve( + bumble: BumbleDevice, config: Config = Config(), grpc_server: Optional[grpc.aio.Server] = None, port: int = 0 +) -> None: # initialize a gRPC server if not provided. server = grpc_server if grpc_server is not None else grpc.aio.server() port = server.add_insecure_port(f'localhost:{port}') - # load IO capability from config. - io_capability_name: str = bumble.config.get('io_capability', 'no_output_no_input').upper() - io_capability: int = getattr(PairingDelegate, io_capability_name) - try: while True: + # load server config from dict. + config.load_from_dict(bumble.config.get('server', {})) + # add Pandora services to the gRPC server. - add_HostServicer_to_server(HostService(server, bumble.device), server) - add_SecurityServicer_to_server(SecurityService(bumble.device, io_capability), server) - add_SecurityStorageServicer_to_server(SecurityStorageService(bumble.device), server) + add_HostServicer_to_server(HostService(server, bumble.device, config), server) + add_SecurityServicer_to_server(SecurityService(bumble.device, config), server) + add_SecurityStorageServicer_to_server(SecurityStorageService(bumble.device, config), server) # call hooks if any. for hook in _SERVICERS_HOOKS: - hook(bumble, server) + hook(bumble, config, server) # open device. await bumble.open() try: - # Pandora require classic devices to to be discoverable & connectable. + # Pandora require classic devices to be discoverable & connectable. if bumble.device.classic_enabled: - await bumble.device.set_discoverable(False) + await bumble.device.set_discoverable(True) await bumble.device.set_connectable(True) # start & serve gRPC server. @@ -86,4 +95,4 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300 if __name__ == '__main__': bumble = BumbleDevice({'transport': f'tcp-client:127.0.0.1:{ROOTCANAL_PORT_CUTTLEFISH}', 'classic_enabled': True}) logging.basicConfig(level=logging.DEBUG) - asyncio.run(serve_bumble(bumble, port=BUMBLE_SERVER_GRPC_PORT)) + asyncio.run(serve(bumble, port=BUMBLE_SERVER_GRPC_PORT)) diff --git a/avatar/bumble_server/config.py b/avatar/bumble_server/config.py new file mode 100644 index 0000000..2e5d44e --- /dev/null +++ b/avatar/bumble_server/config.py @@ -0,0 +1,42 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from bumble.pairing import PairingDelegate +from dataclasses import dataclass +from typing import Any, Dict + + +@dataclass +class Config: + io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT + pairing_sc_enable: bool = True + pairing_mitm_enable: bool = True + pairing_bonding_enable: bool = True + smp_local_initiator_key_distribution: PairingDelegate.KeyDistribution = PairingDelegate.DEFAULT_KEY_DISTRIBUTION + smp_local_responder_key_distribution: PairingDelegate.KeyDistribution = PairingDelegate.DEFAULT_KEY_DISTRIBUTION + + def load_from_dict(self, config: Dict[str, Any]) -> None: + io_capability_name: str = config.get('io_capability', 'no_output_no_input').upper() + self.io_capability = getattr(PairingDelegate, io_capability_name) + self.pairing_sc_enable = config.get('pairing_sc_enable', True) + self.pairing_mitm_enable = config.get('pairing_mitm_enable', True) + self.pairing_bonding_enable = config.get('pairing_bonding_enable', True) + self.smp_local_initiator_key_distribution = config.get( + 'smp_local_initiator_key_distribution', + PairingDelegate.DEFAULT_KEY_DISTRIBUTION, + ) + self.smp_local_responder_key_distribution = config.get( + 'smp_local_responder_key_distribution', + PairingDelegate.DEFAULT_KEY_DISTRIBUTION, + ) diff --git a/avatar/bumble_server/host.py b/avatar/bumble_server/host.py index d9d6e4a..4060601 100644 --- a/avatar/bumble_server/host.py +++ b/avatar/bumble_server/host.py @@ -20,6 +20,7 @@ import logging import struct from . import utils +from .config import Config from bumble.core import ( BT_BR_EDR_TRANSPORT, BT_LE_TRANSPORT, @@ -93,15 +94,13 @@ SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = { class HostService(HostServicer): - grpc_server: grpc.aio.Server - device: Device waited_connections: Set[int] - def __init__(self, grpc_server: grpc.aio.Server, device: Device) -> None: - super().__init__() + def __init__(self, grpc_server: grpc.aio.Server, device: Device, config: Config) -> None: self.log = utils.BumbleServerLoggerAdapter(logging.getLogger(), {'service_name': 'Host', 'device': device}) self.grpc_server = grpc_server self.device = device + self.config = config self.waited_connections = set() @utils.rpc @@ -276,7 +275,7 @@ class HostService(HostServicer): # Retrieve services data for service in self.device.gatt_server.attributes: if isinstance(service, Service) and (service_data := service.get_advertising_data()): - service_uuid = service.uuid.to_hex_str() + service_uuid = service.uuid.to_hex_str('-') if ( service_uuid in request.data.incomplete_service_class_uuids16 or service_uuid in request.data.complete_service_class_uuids16 @@ -630,17 +629,17 @@ class HostService(HostServicer): data: bytes if uuids := cast(List[UUID], ad.get(AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS)): - dt.incomplete_service_class_uuids16.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.incomplete_service_class_uuids16.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if uuids := cast(List[UUID], ad.get(AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS)): - dt.complete_service_class_uuids16.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.complete_service_class_uuids16.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if uuids := cast(List[UUID], ad.get(AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS)): - dt.incomplete_service_class_uuids32.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.incomplete_service_class_uuids32.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if uuids := cast(List[UUID], ad.get(AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS)): - dt.complete_service_class_uuids32.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.complete_service_class_uuids32.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if uuids := cast(List[UUID], ad.get(AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS)): - dt.incomplete_service_class_uuids128.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.incomplete_service_class_uuids128.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if uuids := cast(List[UUID], ad.get(AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS)): - dt.complete_service_class_uuids128.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.complete_service_class_uuids128.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if s := cast(str, ad.get(AdvertisingData.SHORTENED_LOCAL_NAME)): dt.shortened_local_name = s if s := cast(str, ad.get(AdvertisingData.COMPLETE_LOCAL_NAME)): @@ -653,17 +652,17 @@ class HostService(HostServicer): dt.peripheral_connection_interval_min = ij[0] dt.peripheral_connection_interval_max = ij[1] if uuids := cast(List[UUID], ad.get(AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS)): - dt.service_solicitation_uuids16.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.service_solicitation_uuids16.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if uuids := cast(List[UUID], ad.get(AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS)): - dt.service_solicitation_uuids32.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.service_solicitation_uuids32.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if uuids := cast(List[UUID], ad.get(AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS)): - dt.service_solicitation_uuids128.extend(list(map(lambda x: x.to_hex_str(), uuids))) + dt.service_solicitation_uuids128.extend(list(map(lambda x: x.to_hex_str('-'), uuids))) if uuid_data := cast(Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_16_BIT_UUID)): - dt.service_data_uuid16[uuid_data[0].to_hex_str()] = uuid_data[1] + dt.service_data_uuid16[uuid_data[0].to_hex_str('-')] = uuid_data[1] if uuid_data := cast(Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_32_BIT_UUID)): - dt.service_data_uuid32[uuid_data[0].to_hex_str()] = uuid_data[1] + dt.service_data_uuid32[uuid_data[0].to_hex_str('-')] = uuid_data[1] if uuid_data := cast(Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_128_BIT_UUID)): - dt.service_data_uuid128[uuid_data[0].to_hex_str()] = uuid_data[1] + dt.service_data_uuid128[uuid_data[0].to_hex_str('-')] = uuid_data[1] if data := cast(bytes, ad.get(AdvertisingData.PUBLIC_TARGET_ADDRESS, raw=True)): dt.public_target_addresses.extend([data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]) if data := cast(bytes, ad.get(AdvertisingData.RANDOM_TARGET_ADDRESS, raw=True)): diff --git a/avatar/bumble_server/security.py b/avatar/bumble_server/security.py index 000d49f..279aa42 100644 --- a/avatar/bumble_server/security.py +++ b/avatar/bumble_server/security.py @@ -17,11 +17,12 @@ import grpc import logging from . import utils +from .config import Config from bumble import hci from bumble.core import BT_BR_EDR_TRANSPORT, BT_LE_TRANSPORT, BT_PERIPHERAL_ROLE, ProtocolError from bumble.device import Connection as BumbleConnection, Device from bumble.hci import HCI_Error -from bumble.smp import PairingConfig, PairingDelegate as BasePairingDelegate +from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate from contextlib import suppress from google.protobuf import any_pb2, empty_pb2, wrappers_pb2 # pytype: disable=pyi-error from google.protobuf.wrappers_pb2 import BoolValue # pytype: disable=pyi-error @@ -48,7 +49,7 @@ from pandora.security_pb2 import ( WaitSecurityRequest, WaitSecurityResponse, ) -from typing import Any, AsyncGenerator, AsyncIterator, Callable, Dict, Optional, Union, cast +from typing import Any, AsyncGenerator, AsyncIterator, Callable, Dict, Optional, Union class PairingDelegate(BasePairingDelegate): @@ -56,9 +57,9 @@ class PairingDelegate(BasePairingDelegate): self, connection: BumbleConnection, service: "SecurityService", - io_capability: int = BasePairingDelegate.NO_OUTPUT_NO_INPUT, - local_initiator_key_distribution: int = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION, - local_responder_key_distribution: int = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION, + io_capability: BasePairingDelegate.IoCapability = BasePairingDelegate.NO_OUTPUT_NO_INPUT, + local_initiator_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION, + local_responder_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION, ) -> None: self.log = utils.BumbleServerLoggerAdapter( logging.getLogger(), {'service_name': 'Security', 'device': connection.device} @@ -82,7 +83,7 @@ class PairingDelegate(BasePairingDelegate): return ev - async def confirm(self) -> bool: + async def confirm(self, auto: bool = False) -> bool: self.log.info(f"Pairing event: `just_works` (io_capability: {self.io_capability})") if self.service.event_queue is None or self.service.event_answer is None: @@ -143,6 +144,12 @@ class PairingDelegate(BasePairingDelegate): return pin async def display_number(self, number: int, digits: int = 6) -> None: + if ( + self.connection.transport == BT_BR_EDR_TRANSPORT + and self.io_capability == BasePairingDelegate.DISPLAY_OUTPUT_ONLY + ): + return + self.log.info(f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})") if self.service.event_queue is None: @@ -177,23 +184,27 @@ LE_LEVEL_REACHED: Dict[LESecurityLevel, Callable[[BumbleConnection], bool]] = { class SecurityService(SecurityServicer): - def __init__(self, device: Device, io_capability: int) -> None: + def __init__(self, device: Device, config: Config) -> None: self.log = utils.BumbleServerLoggerAdapter(logging.getLogger(), {'service_name': 'Security', 'device': device}) self.event_queue: Optional[asyncio.Queue[PairingEvent]] = None self.event_answer: Optional[AsyncIterator[PairingEventAnswer]] = None self.device = device + self.config = config def pairing_config_factory(connection: BumbleConnection) -> PairingConfig: return PairingConfig( - sc=True, - mitm=True, - bonding=True, + sc=config.pairing_sc_enable, + mitm=config.pairing_mitm_enable, + bonding=config.pairing_bonding_enable, delegate=PairingDelegate( - connection, self, io_capability=cast(int, getattr(self.device, 'io_capability')) + connection, + self, + io_capability=config.io_capability, + local_initiator_key_distribution=config.smp_local_initiator_key_distribution, + local_responder_key_distribution=config.smp_local_responder_key_distribution, ), ) - setattr(device, 'io_capability', io_capability) self.device.pairing_config_factory = pairing_config_factory @utils.rpc @@ -253,7 +264,7 @@ class SecurityService(SecurityServicer): self.log.info('Paired') except asyncio.CancelledError: - self.log.warning(f"Connection died during encryption") + self.log.warning("Connection died during encryption") return SecureResponse(connection_died=empty_pb2.Empty()) except (HCI_Error, ProtocolError) as e: self.log.warning(f"Pairing failure: {e}") @@ -266,7 +277,7 @@ class SecurityService(SecurityServicer): await connection.authenticate() self.log.info('Authenticated') except asyncio.CancelledError: - self.log.warning(f"Connection died during authentication") + self.log.warning("Connection died during authentication") return SecureResponse(connection_died=empty_pb2.Empty()) except (HCI_Error, ProtocolError) as e: self.log.warning(f"Authentication failure: {e}") @@ -279,7 +290,7 @@ class SecurityService(SecurityServicer): await connection.encrypt() self.log.info('Encrypted') except asyncio.CancelledError: - self.log.warning(f"Connection died during encryption") + self.log.warning("Connection died during encryption") return SecureResponse(connection_died=empty_pb2.Empty()) except (HCI_Error, ProtocolError) as e: self.log.warning(f"Encryption failure: {e}") @@ -334,13 +345,13 @@ class SecurityService(SecurityServicer): def try_set_success(*_: Any) -> None: assert connection if self.reached_security_level(connection, level): - self.log.info(f'Wait for security: done') + self.log.info('Wait for security: done') wait_for_security.set_result('success') def on_encryption_change(*_: Any) -> None: assert connection if self.reached_security_level(connection, level): - self.log.info(f'Wait for security: done') + self.log.info('Wait for security: done') wait_for_security.set_result('success') elif connection.transport == BT_BR_EDR_TRANSPORT and self.need_authentication(connection, level): nonlocal authenticate_task @@ -424,11 +435,12 @@ class SecurityService(SecurityServicer): class SecurityStorageService(SecurityStorageServicer): - def __init__(self, device: Device) -> None: + def __init__(self, device: Device, config: Config) -> None: self.log = utils.BumbleServerLoggerAdapter( logging.getLogger(), {'service_name': 'SecurityStorage', 'device': device} ) self.device = device + self.config = config @utils.rpc async def IsBonded(self, request: IsBondedRequest, context: grpc.ServicerContext) -> wrappers_pb2.BoolValue: diff --git a/avatar/pandora_client.py b/avatar/pandora_client.py index 548e6d1..e603fef 100644 --- a/avatar/pandora_client.py +++ b/avatar/pandora_client.py @@ -23,6 +23,7 @@ import grpc import grpc.aio import logging +from avatar import bumble_server from avatar.bumble_device import BumbleDevice from bumble.hci import Address as BumbleAddress from dataclasses import dataclass @@ -186,10 +187,16 @@ class BumblePandoraClient(PandoraClient): """Special Pandora client which also give access to a Bumble device instance.""" _bumble: BumbleDevice # Bumble device wrapper. + _server_config: bumble_server.Config # Bumble server config. - def __init__(self, grpc_target: str, bumble: BumbleDevice) -> None: + def __init__(self, grpc_target: str, bumble: BumbleDevice, server_config: bumble_server.Config) -> None: super().__init__(grpc_target, 'bumble') self._bumble = bumble + self._server_config = server_config + + @property + def server_config(self) -> bumble_server.Config: + return self._server_config @property def config(self) -> Dict[str, Any]: diff --git a/avatar/pandora_server.py b/avatar/pandora_server.py index a947ea8..eab92c4 100644 --- a/avatar/pandora_server.py +++ b/avatar/pandora_server.py @@ -22,8 +22,8 @@ import grpc.aio import threading import types +from avatar import bumble_server from avatar.bumble_device import BumbleDevice -from avatar.bumble_server import serve_bumble from avatar.controllers import bumble_device, pandora_device from avatar.pandora_client import BumblePandoraClient, PandoraClient from contextlib import suppress @@ -81,15 +81,12 @@ class BumblePandoraServer(PandoraServer[BumbleDevice]): server = grpc.aio.server() port = server.add_insecure_port(f'localhost:{0}') + config = bumble_server.Config() self._task = avatar.aio.loop.create_task( - serve_bumble( - self.device, - grpc_server=server, - port=port, - ) + bumble_server.serve(self.device, config=config, grpc_server=server, port=port) ) - return BumblePandoraClient(f'localhost:{port}', self.device) + return BumblePandoraClient(f'localhost:{port}', self.device, config) def stop(self) -> None: """Stops and cleans up the Pandora server on the Bumble device.""" |