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# ----------------------------------------------------------------------------- 17# Imports 18# ----------------------------------------------------------------------------- 19import struct 20import logging 21from typing import List, Optional 22 23from bumble import l2cap 24from ..core import AdvertisingData 25from ..device import Device, Connection 26from ..gatt import ( 27 GATT_ASHA_SERVICE, 28 GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, 29 GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, 30 GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC, 31 GATT_ASHA_VOLUME_CHARACTERISTIC, 32 GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, 33 TemplateService, 34 Characteristic, 35 CharacteristicValue, 36) 37from ..utils import AsyncRunner 38 39# ----------------------------------------------------------------------------- 40# Logging 41# ----------------------------------------------------------------------------- 42logger = logging.getLogger(__name__) 43 44 45# ----------------------------------------------------------------------------- 46class AshaService(TemplateService): 47 UUID = GATT_ASHA_SERVICE 48 OPCODE_START = 1 49 OPCODE_STOP = 2 50 OPCODE_STATUS = 3 51 PROTOCOL_VERSION = 0x01 52 RESERVED_FOR_FUTURE_USE = [00, 00] 53 FEATURE_MAP = [0x01] # [LE CoC audio output streaming supported] 54 SUPPORTED_CODEC_ID = [0x02, 0x01] # Codec IDs [G.722 at 16 kHz] 55 RENDER_DELAY = [00, 00] 56 57 def __init__(self, capability: int, hisyncid: List[int], device: Device, psm=0): 58 self.hisyncid = hisyncid 59 self.capability = capability # Device Capabilities [Left, Monaural] 60 self.device = device 61 self.audio_out_data = b'' 62 self.psm = psm # a non-zero psm is mainly for testing purpose 63 64 # Handler for volume control 65 def on_volume_write(connection, value): 66 logger.info(f'--- VOLUME Write:{value[0]}') 67 self.emit('volume', connection, value[0]) 68 69 # Handler for audio control commands 70 def on_audio_control_point_write(connection: Optional[Connection], value): 71 logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}') 72 opcode = value[0] 73 if opcode == AshaService.OPCODE_START: 74 # Start 75 audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]] 76 logger.info( 77 f'### START: codec={value[1]}, ' 78 f'audio_type={audio_type}, ' 79 f'volume={value[3]}, ' 80 f'otherstate={value[4]}' 81 ) 82 self.emit( 83 'start', 84 connection, 85 { 86 'codec': value[1], 87 'audiotype': value[2], 88 'volume': value[3], 89 'otherstate': value[4], 90 }, 91 ) 92 elif opcode == AshaService.OPCODE_STOP: 93 logger.info('### STOP') 94 self.emit('stop', connection) 95 elif opcode == AshaService.OPCODE_STATUS: 96 logger.info(f'### STATUS: connected={value[1]}') 97 98 # OPCODE_STATUS does not need audio status point update 99 if opcode != AshaService.OPCODE_STATUS: 100 AsyncRunner.spawn( 101 device.notify_subscribers( 102 self.audio_status_characteristic, force=True 103 ) 104 ) 105 106 self.read_only_properties_characteristic = Characteristic( 107 GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, 108 Characteristic.Properties.READ, 109 Characteristic.READABLE, 110 bytes( 111 [ 112 AshaService.PROTOCOL_VERSION, # Version 113 self.capability, 114 ] 115 ) 116 + bytes(self.hisyncid) 117 + bytes(AshaService.FEATURE_MAP) 118 + bytes(AshaService.RENDER_DELAY) 119 + bytes(AshaService.RESERVED_FOR_FUTURE_USE) 120 + bytes(AshaService.SUPPORTED_CODEC_ID), 121 ) 122 123 self.audio_control_point_characteristic = Characteristic( 124 GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, 125 Characteristic.Properties.WRITE 126 | Characteristic.Properties.WRITE_WITHOUT_RESPONSE, 127 Characteristic.WRITEABLE, 128 CharacteristicValue(write=on_audio_control_point_write), 129 ) 130 self.audio_status_characteristic = Characteristic( 131 GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC, 132 Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, 133 Characteristic.READABLE, 134 bytes([0]), 135 ) 136 self.volume_characteristic = Characteristic( 137 GATT_ASHA_VOLUME_CHARACTERISTIC, 138 Characteristic.Properties.WRITE_WITHOUT_RESPONSE, 139 Characteristic.WRITEABLE, 140 CharacteristicValue(write=on_volume_write), 141 ) 142 143 # Register an L2CAP CoC server 144 def on_coc(channel): 145 def on_data(data): 146 logging.debug(f'<<< data received:{data}') 147 148 self.emit('data', channel.connection, data) 149 self.audio_out_data += data 150 151 channel.sink = on_data 152 153 # let the server find a free PSM 154 self.psm = device.create_l2cap_server( 155 spec=l2cap.LeCreditBasedChannelSpec(psm=self.psm, max_credits=8), 156 handler=on_coc, 157 ).psm 158 self.le_psm_out_characteristic = Characteristic( 159 GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, 160 Characteristic.Properties.READ, 161 Characteristic.READABLE, 162 struct.pack('<H', self.psm), 163 ) 164 165 characteristics = [ 166 self.read_only_properties_characteristic, 167 self.audio_control_point_characteristic, 168 self.audio_status_characteristic, 169 self.volume_characteristic, 170 self.le_psm_out_characteristic, 171 ] 172 173 super().__init__(characteristics) 174 175 def get_advertising_data(self): 176 # Advertisement only uses 4 least significant bytes of the HiSyncId. 177 return bytes( 178 AdvertisingData( 179 [ 180 ( 181 AdvertisingData.SERVICE_DATA_16_BIT_UUID, 182 bytes(GATT_ASHA_SERVICE) 183 + bytes( 184 [ 185 AshaService.PROTOCOL_VERSION, 186 self.capability, 187 ] 188 ) 189 + bytes(self.hisyncid[:4]), 190 ), 191 ] 192 ) 193 ) 194