1# Copyright 2021-2024 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 sys 21import os 22import secrets 23import websockets 24import json 25 26from bumble.core import AdvertisingData 27from bumble.device import Device, AdvertisingParameters, AdvertisingEventProperties 28from bumble.hci import ( 29 CodecID, 30 CodingFormat, 31 OwnAddressType, 32) 33from bumble.profiles.ascs import AudioStreamControlService 34from bumble.profiles.bap import ( 35 UnicastServerAdvertisingData, 36 CodecSpecificCapabilities, 37 ContextType, 38 AudioLocation, 39 SupportedSamplingFrequency, 40 SupportedFrameDuration, 41) 42from bumble.profiles.pacs import PacRecord, PublishedAudioCapabilitiesService 43from bumble.profiles.cap import CommonAudioServiceService 44from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType 45from bumble.profiles.vcp import VolumeControlService 46 47from bumble.transport import open_transport_or_link 48 49from typing import Optional 50 51 52def dumps_volume_state(volume_setting: int, muted: int, change_counter: int) -> str: 53 return json.dumps( 54 { 55 'volume_setting': volume_setting, 56 'muted': muted, 57 'change_counter': change_counter, 58 } 59 ) 60 61 62# ----------------------------------------------------------------------------- 63async def main() -> None: 64 if len(sys.argv) < 3: 65 print('Usage: run_vcp_renderer.py <config-file>' '<transport-spec-for-device>') 66 return 67 68 print('<<< connecting to HCI...') 69 async with await open_transport_or_link(sys.argv[2]) as hci_transport: 70 print('<<< connected') 71 72 device = Device.from_config_file_with_hci( 73 sys.argv[1], hci_transport.source, hci_transport.sink 74 ) 75 76 await device.power_on() 77 78 # Add "placeholder" services to enable Android LEA features. 79 csis = CoordinatedSetIdentificationService( 80 set_identity_resolving_key=secrets.token_bytes(16), 81 set_identity_resolving_key_type=SirkType.PLAINTEXT, 82 ) 83 device.add_service(CommonAudioServiceService(csis)) 84 device.add_service( 85 PublishedAudioCapabilitiesService( 86 supported_source_context=ContextType.PROHIBITED, 87 available_source_context=ContextType.PROHIBITED, 88 supported_sink_context=ContextType.MEDIA, 89 available_sink_context=ContextType.MEDIA, 90 sink_audio_locations=( 91 AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT 92 ), 93 sink_pac=[ 94 # Codec Capability Setting 48_4 95 PacRecord( 96 coding_format=CodingFormat(CodecID.LC3), 97 codec_specific_capabilities=CodecSpecificCapabilities( 98 supported_sampling_frequencies=( 99 SupportedSamplingFrequency.FREQ_48000 100 ), 101 supported_frame_durations=( 102 SupportedFrameDuration.DURATION_10000_US_SUPPORTED 103 ), 104 supported_audio_channel_count=[1], 105 min_octets_per_codec_frame=120, 106 max_octets_per_codec_frame=120, 107 supported_max_codec_frames_per_sdu=1, 108 ), 109 ), 110 ], 111 ) 112 ) 113 device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2])) 114 115 vcs = VolumeControlService() 116 device.add_service(vcs) 117 118 ws: Optional[websockets.WebSocketServerProtocol] = None 119 120 def on_volume_state(volume_setting: int, muted: int, change_counter: int): 121 if ws: 122 asyncio.create_task( 123 ws.send(dumps_volume_state(volume_setting, muted, change_counter)) 124 ) 125 126 vcs.on('volume_state', on_volume_state) 127 128 advertising_data = ( 129 bytes( 130 AdvertisingData( 131 [ 132 ( 133 AdvertisingData.COMPLETE_LOCAL_NAME, 134 bytes('Bumble LE Audio', 'utf-8'), 135 ), 136 ( 137 AdvertisingData.FLAGS, 138 bytes( 139 [ 140 AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG 141 | AdvertisingData.BR_EDR_HOST_FLAG 142 | AdvertisingData.BR_EDR_CONTROLLER_FLAG 143 ] 144 ), 145 ), 146 ( 147 AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, 148 bytes(PublishedAudioCapabilitiesService.UUID), 149 ), 150 ] 151 ) 152 ) 153 + csis.get_advertising_data() 154 + bytes(UnicastServerAdvertisingData()) 155 ) 156 157 await device.create_advertising_set( 158 advertising_parameters=AdvertisingParameters( 159 advertising_event_properties=AdvertisingEventProperties(), 160 own_address_type=OwnAddressType.PUBLIC, 161 ), 162 advertising_data=advertising_data, 163 ) 164 165 async def serve(websocket: websockets.WebSocketServerProtocol, _path): 166 nonlocal ws 167 await websocket.send( 168 dumps_volume_state(vcs.volume_setting, vcs.muted, vcs.change_counter) 169 ) 170 ws = websocket 171 async for message in websocket: 172 volume_state = json.loads(message) 173 vcs.volume_state_bytes = bytes( 174 [ 175 volume_state['volume_setting'], 176 volume_state['muted'], 177 volume_state['change_counter'], 178 ] 179 ) 180 await device.notify_subscribers( 181 vcs.volume_state, vcs.volume_state_bytes 182 ) 183 ws = None 184 185 await websockets.serve(serve, 'localhost', 8989) 186 187 await hci_transport.source.terminated 188 189 190# ----------------------------------------------------------------------------- 191logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) 192asyncio.run(main()) 193