• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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