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 struct 20import sys 21import os 22import logging 23 24from bumble import l2cap 25from bumble.core import AdvertisingData 26from bumble.device import Device 27from bumble.transport import open_transport_or_link 28from bumble.core import UUID 29from bumble.gatt import Service, Characteristic, CharacteristicValue 30 31 32# ----------------------------------------------------------------------------- 33# Constants 34# ----------------------------------------------------------------------------- 35ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid') 36ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID( 37 '6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties' 38) 39ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC = UUID( 40 'f0d4de7e-4a88-476c-9d9f-1937b0996cc0', 'AudioControlPoint' 41) 42ASHA_AUDIO_STATUS_CHARACTERISTIC = UUID( 43 '38663f1a-e711-4cac-b641-326b56404837', 'AudioStatus' 44) 45ASHA_VOLUME_CHARACTERISTIC = UUID('00e4ca9e-ab14-41e4-8823-f9e70c7e91df', 'Volume') 46ASHA_LE_PSM_OUT_CHARACTERISTIC = UUID( 47 '2d410339-82b6-42aa-b34e-e2e01df8cc1a', 'LE_PSM_OUT' 48) 49 50 51# ----------------------------------------------------------------------------- 52async def main() -> None: 53 if len(sys.argv) != 4: 54 print( 55 'Usage: python run_asha_sink.py <device-config> <transport-spec> ' 56 '<audio-file>' 57 ) 58 print('example: python run_asha_sink.py device1.json usb:0 audio_out.g722') 59 return 60 61 audio_out = open(sys.argv[3], 'wb') 62 63 async with await open_transport_or_link(sys.argv[2]) as hci_transport: 64 device = Device.from_config_file_with_hci( 65 sys.argv[1], hci_transport.source, hci_transport.sink 66 ) 67 68 # Handler for audio control commands 69 def on_audio_control_point_write(_connection, value): 70 print('--- AUDIO CONTROL POINT Write:', value.hex()) 71 opcode = value[0] 72 if opcode == 1: 73 # Start 74 audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]] 75 print( 76 f'### START: codec={value[1]}, audio_type={audio_type}, ' 77 f'volume={value[3]}, otherstate={value[4]}' 78 ) 79 elif opcode == 2: 80 print('### STOP') 81 elif opcode == 3: 82 print(f'### STATUS: connected={value[1]}') 83 84 # Respond with a status 85 asyncio.create_task( 86 device.notify_subscribers(audio_status_characteristic, force=True) 87 ) 88 89 # Handler for volume control 90 def on_volume_write(_connection, value): 91 print('--- VOLUME Write:', value[0]) 92 93 # Register an L2CAP CoC server 94 def on_coc(channel): 95 def on_data(data): 96 print('<<< Voice data received:', data.hex()) 97 audio_out.write(data) 98 99 channel.sink = on_data 100 101 server = device.create_l2cap_server( 102 spec=l2cap.LeCreditBasedChannelSpec(max_credits=8), handler=on_coc 103 ) 104 print(f'### LE_PSM_OUT = {server.psm}') 105 106 # Add the ASHA service to the GATT server 107 read_only_properties_characteristic = Characteristic( 108 ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, 109 Characteristic.Properties.READ, 110 Characteristic.READABLE, 111 bytes( 112 [ 113 0x01, # Version 114 0x00, # Device Capabilities [Left, Monaural] 115 0x01, 116 0x02, 117 0x03, 118 0x04, 119 0x05, 120 0x06, 121 0x07, 122 0x08, # HiSyncId 123 0x01, # Feature Map [LE CoC audio output streaming supported] 124 0x00, 125 0x00, # Render Delay 126 0x00, 127 0x00, # RFU 128 0x02, 129 0x00, # Codec IDs [G.722 at 16 kHz] 130 ] 131 ), 132 ) 133 audio_control_point_characteristic = Characteristic( 134 ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, 135 Characteristic.Properties.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE, 136 Characteristic.WRITEABLE, 137 CharacteristicValue(write=on_audio_control_point_write), 138 ) 139 audio_status_characteristic = Characteristic( 140 ASHA_AUDIO_STATUS_CHARACTERISTIC, 141 Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, 142 Characteristic.READABLE, 143 bytes([0]), 144 ) 145 volume_characteristic = Characteristic( 146 ASHA_VOLUME_CHARACTERISTIC, 147 Characteristic.WRITE_WITHOUT_RESPONSE, 148 Characteristic.WRITEABLE, 149 CharacteristicValue(write=on_volume_write), 150 ) 151 le_psm_out_characteristic = Characteristic( 152 ASHA_LE_PSM_OUT_CHARACTERISTIC, 153 Characteristic.Properties.READ, 154 Characteristic.READABLE, 155 struct.pack('<H', server.psm), 156 ) 157 device.add_service( 158 Service( 159 ASHA_SERVICE, 160 [ 161 read_only_properties_characteristic, 162 audio_control_point_characteristic, 163 audio_status_characteristic, 164 volume_characteristic, 165 le_psm_out_characteristic, 166 ], 167 ) 168 ) 169 170 # Set the advertising data 171 device.advertising_data = bytes( 172 AdvertisingData( 173 [ 174 (AdvertisingData.COMPLETE_LOCAL_NAME, bytes(device.name, 'utf-8')), 175 (AdvertisingData.FLAGS, bytes([0x06])), 176 ( 177 AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, 178 bytes(ASHA_SERVICE), 179 ), 180 ( 181 AdvertisingData.SERVICE_DATA_16_BIT_UUID, 182 bytes(ASHA_SERVICE) 183 + bytes( 184 [ 185 0x01, # Protocol Version 186 0x00, # Capability 187 0x01, 188 0x02, 189 0x03, 190 0x04, # Truncated HiSyncID 191 ] 192 ), 193 ), 194 ] 195 ) 196 ) 197 198 # Go! 199 await device.power_on() 200 await device.start_advertising(auto_restart=True) 201 202 await hci_transport.source.wait_for_termination() 203 204 205# ----------------------------------------------------------------------------- 206logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) 207asyncio.run(main()) 208