From 22f21fa0eb178ed900ff13367a3980e34e8d1d15 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Thu, 25 Jan 2024 20:32:39 +0800 Subject: [PATCH] Add Volume Control Service --- .vscode/settings.json | 2 + bumble/profiles/vcp.py | 229 +++++++++++++++++++++++++++++++++ examples/run_unicast_server.py | 8 +- examples/run_vcp_renderer.py | 193 +++++++++++++++++++++++++++ examples/vcp_renderer.html | 102 +++++++++++++++ tests/vcp_test.py | 122 ++++++++++++++++++ 6 files changed, 652 insertions(+), 4 deletions(-) create mode 100644 bumble/profiles/vcp.py create mode 100644 examples/run_vcp_renderer.py create mode 100644 examples/vcp_renderer.html create mode 100644 tests/vcp_test.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 93e9ece33..98b1d02f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -72,6 +72,8 @@ "substates", "tobytes", "tsep", + "UNMUTE", + "unmuted", "usbmodem", "vhci", "websockets", diff --git a/bumble/profiles/vcp.py b/bumble/profiles/vcp.py new file mode 100644 index 000000000..00bf5fd76 --- /dev/null +++ b/bumble/profiles/vcp.py @@ -0,0 +1,229 @@ +# Copyright 2021-2024 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. + + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations +import enum +import pyee + +from bumble import att +from bumble import device +from bumble import gatt +from bumble import gatt_client + +from typing import Optional + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- + +MIN_VOLUME = 0 +MAX_VOLUME = 255 + + +class ErrorCode(enum.IntEnum): + ''' + See Volume Control Service 1.6. Application error codes. + ''' + + INVALID_CHANGE_COUNTER = 0x80 + OPCODE_NOT_SUPPORTED = 0x81 + + +class VolumeFlags(enum.IntFlag): + ''' + See Volume Control Service 3.3. Volume Flags. + ''' + + VOLUME_SETTING_PERSISTED = 0x01 + # RFU + + +class VolumeControlPointOpcode(enum.IntEnum): + ''' + See Volume Control Service Table 3.3: Volume Control Point procedure requirements. + ''' + + # fmt: off + RELATIVE_VOLUME_DOWN = 0x00 + RELATIVE_VOLUME_UP = 0x01 + UNMUTE_RELATIVE_VOLUME_DOWN = 0x02 + UNMUTE_RELATIVE_VOLUME_UP = 0x03 + SET_ABSOLUTE_VOLUME = 0x04 + UNMUTE = 0x05 + MUTE = 0x06 + + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- +class VolumeControlService(gatt.TemplateService): + UUID = gatt.GATT_VOLUME_CONTROL_SERVICE + + volume_state: gatt.Characteristic + volume_control_point: gatt.Characteristic + volume_flags: gatt.Characteristic + + volume_setting: int + muted: int + change_counter: int + + def __init__( + self, + step_size: int = 16, + volume_setting: int = 0, + muted: int = 0, + change_counter: int = 0, + volume_flags: int = 0, + ) -> None: + self.step_size = step_size + self.volume_setting = volume_setting + self.muted = muted + self.change_counter = change_counter + + self.volume_state = gatt.Characteristic( + uuid=gatt.GATT_VOLUME_STATE_CHARACTERISTIC, + properties=( + gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.NOTIFY + ), + permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION, + value=gatt.CharacteristicValue(read=self._on_read_volume_state), + ) + self.volume_control_point = gatt.Characteristic( + uuid=gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.WRITE, + permissions=gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION, + value=gatt.CharacteristicValue(write=self._on_write_volume_control_point), + ) + self.volume_flags = gatt.Characteristic( + uuid=gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ, + permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION, + value=bytes([volume_flags]), + ) + + super().__init__( + [ + self.volume_state, + self.volume_control_point, + self.volume_flags, + ] + ) + + @property + def volume_state_bytes(self) -> bytes: + return bytes([self.volume_setting, self.muted, self.change_counter]) + + @volume_state_bytes.setter + def volume_state_bytes(self, new_value: bytes) -> None: + self.volume_setting, self.muted, self.change_counter = new_value + + def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes: + return self.volume_state_bytes + + def _on_write_volume_control_point( + self, connection: Optional[device.Connection], value: bytes + ) -> None: + assert connection + + opcode = VolumeControlPointOpcode(value[0]) + change_counter = value[1] + + if change_counter != self.change_counter: + raise att.ATT_Error(ErrorCode.INVALID_CHANGE_COUNTER) + + handler = getattr(self, '_on_' + opcode.name.lower()) + if handler(*value[2:]): + self.change_counter = (self.change_counter + 1) % 256 + connection.abort_on( + 'disconnection', + connection.device.notify_subscribers( + attribute=self.volume_state, + value=self.volume_state_bytes, + ), + ) + self.emit( + 'volume_state', self.volume_setting, self.muted, self.change_counter + ) + + def _on_relative_volume_down(self) -> bool: + old_volume = self.volume_setting + self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME) + return self.volume_setting != old_volume + + def _on_relative_volume_up(self) -> bool: + old_volume = self.volume_setting + self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME) + return self.volume_setting != old_volume + + def _on_unmute_relative_volume_down(self) -> bool: + old_volume, old_muted_state = self.volume_setting, self.muted + self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME) + self.muted = 0 + return (self.volume_setting, self.muted) != (old_volume, old_muted_state) + + def _on_unmute_relative_volume_up(self) -> bool: + old_volume, old_muted_state = self.volume_setting, self.muted + self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME) + self.muted = 0 + return (self.volume_setting, self.muted) != (old_volume, old_muted_state) + + def _on_set_absolute_volume(self, volume_setting) -> bool: + old_volume_setting = self.volume_setting + self.volume_setting = volume_setting + return old_volume_setting != self.volume_setting + + def _on_unmute(self) -> bool: + old_muted_state = self.muted + self.muted = 0 + return self.muted != old_muted_state + + def _on_mute(self) -> bool: + old_muted_state = self.muted + self.muted = 1 + return self.muted != old_muted_state + + +# ----------------------------------------------------------------------------- +# Client +# ----------------------------------------------------------------------------- +class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy): + SERVICE_CLASS = VolumeControlService + + volume_control_point: gatt_client.CharacteristicProxy + + def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None: + self.service_proxy = service_proxy + + self.volume_state = gatt.PackedCharacteristicAdapter( + service_proxy.get_characteristics_by_uuid( + gatt.GATT_VOLUME_STATE_CHARACTERISTIC + )[0], + 'BBB', + ) + + self.volume_control_point = service_proxy.get_characteristics_by_uuid( + gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC + )[0] + + self.volume_flags = gatt.PackedCharacteristicAdapter( + service_proxy.get_characteristics_by_uuid( + gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC + )[0], + 'B', + ) diff --git a/examples/run_unicast_server.py b/examples/run_unicast_server.py index 35e124d48..7a63f51df 100644 --- a/examples/run_unicast_server.py +++ b/examples/run_unicast_server.py @@ -99,14 +99,14 @@ async def main() -> None: coding_format=CodingFormat(CodecID.LC3), codec_specific_capabilities=CodecSpecificCapabilities( supported_sampling_frequencies=( - SupportedSamplingFrequency.FREQ_24000 + SupportedSamplingFrequency.FREQ_48000 ), supported_frame_durations=( SupportedFrameDuration.DURATION_10000_US_SUPPORTED ), supported_audio_channel_counts=[1], - min_octets_per_codec_frame=60, - max_octets_per_codec_frame=60, + min_octets_per_codec_frame=120, + max_octets_per_codec_frame=120, supported_max_codec_frames_per_sdu=1, ), ), @@ -159,7 +159,7 @@ async def main() -> None: + struct.pack( ' str: + return json.dumps( + { + 'volume_setting': volume_setting, + 'muted': muted, + 'change_counter': change_counter, + } + ) + + +# ----------------------------------------------------------------------------- +async def main() -> None: + if len(sys.argv) < 3: + print('Usage: run_vcp_renderer.py ' '') + return + + print('<<< connecting to HCI...') + async with await open_transport_or_link(sys.argv[2]) as hci_transport: + print('<<< connected') + + device = Device.from_config_file_with_hci( + sys.argv[1], hci_transport.source, hci_transport.sink + ) + device.cis_enabled = True + + await device.power_on() + + # Add "placeholder" services to enable Android LEA features. + csis = CoordinatedSetIdentificationService( + set_identity_resolving_key=secrets.token_bytes(16), + set_identity_resolving_key_type=SirkType.PLAINTEXT, + ) + device.add_service(CommonAudioServiceService(csis)) + device.add_service( + PublishedAudioCapabilitiesService( + supported_source_context=ContextType.PROHIBITED, + available_source_context=ContextType.PROHIBITED, + supported_sink_context=ContextType.MEDIA, + available_sink_context=ContextType.MEDIA, + sink_audio_locations=( + AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT + ), + sink_pac=[ + # Codec Capability Setting 48_4 + PacRecord( + coding_format=CodingFormat(CodecID.LC3), + codec_specific_capabilities=CodecSpecificCapabilities( + supported_sampling_frequencies=( + SupportedSamplingFrequency.FREQ_48000 + ), + supported_frame_durations=( + SupportedFrameDuration.DURATION_10000_US_SUPPORTED + ), + supported_audio_channel_counts=[1], + min_octets_per_codec_frame=120, + max_octets_per_codec_frame=120, + supported_max_codec_frames_per_sdu=1, + ), + ), + ], + ) + ) + device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2])) + + vcs = VolumeControlService() + device.add_service(vcs) + + ws: Optional[websockets.WebSocketServerProtocol] = None + + def on_volume_state(volume_setting: int, muted: int, change_counter: int): + if ws: + asyncio.create_task( + ws.send(dumps_volume_state(volume_setting, muted, change_counter)) + ) + + vcs.on('volume_state', on_volume_state) + + advertising_data = ( + bytes( + AdvertisingData( + [ + ( + AdvertisingData.COMPLETE_LOCAL_NAME, + bytes('Bumble LE Audio', 'utf-8'), + ), + ( + AdvertisingData.FLAGS, + bytes( + [ + AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG + | AdvertisingData.BR_EDR_HOST_FLAG + | AdvertisingData.BR_EDR_CONTROLLER_FLAG + ] + ), + ), + ( + AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, + bytes(PublishedAudioCapabilitiesService.UUID), + ), + ] + ) + ) + + csis.get_advertising_data() + ) + + await device.start_extended_advertising( + advertising_properties=( + HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING + ), + own_address_type=OwnAddressType.RANDOM, + advertising_data=advertising_data, + ) + + async def serve(websocket: websockets.WebSocketServerProtocol, _path): + nonlocal ws + await websocket.send( + dumps_volume_state(vcs.volume_setting, vcs.muted, vcs.change_counter) + ) + ws = websocket + async for message in websocket: + volume_state = json.loads(message) + vcs.volume_state_bytes = bytes( + [ + volume_state['volume_setting'], + volume_state['muted'], + volume_state['change_counter'], + ] + ) + await device.notify_subscribers( + vcs.volume_state, vcs.volume_state_bytes + ) + ws = None + + await websockets.serve(serve, 'localhost', 8989) + + await hci_transport.source.terminated + + +# ----------------------------------------------------------------------------- +logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) +asyncio.run(main()) diff --git a/examples/vcp_renderer.html b/examples/vcp_renderer.html new file mode 100644 index 000000000..75a899c69 --- /dev/null +++ b/examples/vcp_renderer.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + +
+ + +
+ + +
+ Volume Setting + + Muted + + Change Counter + +
+ + + +
+
+

Log

+
+
+ + + + + + \ No newline at end of file diff --git a/tests/vcp_test.py b/tests/vcp_test.py new file mode 100644 index 000000000..5accdc49f --- /dev/null +++ b/tests/vcp_test.py @@ -0,0 +1,122 @@ +# Copyright 2021-2023 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. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import os +import pytest +import logging + +from bumble import device +from bumble import gatt +from bumble.profiles import vcp +from .test_utils import TwoDevices + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +@pytest.fixture +async def vcp_client(): + devices = TwoDevices() + devices[0].add_service( + vcp.VolumeControlService(volume_setting=32, muted=1, volume_flags=1) + ) + + await devices.setup_connection() + + # Mock encryption. + devices.connections[0].encryption = 1 + devices.connections[1].encryption = 1 + + peer = device.Peer(devices.connections[1]) + vcp_client = await peer.discover_service_and_create_proxy( + vcp.VolumeControlServiceProxy + ) + yield vcp_client + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_init_service(vcp_client: vcp.VolumeControlServiceProxy): + assert (await vcp_client.volume_flags.read_value()) == 1 + assert (await vcp_client.volume_state.read_value()) == (32, 1, 0) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy): + await vcp_client.volume_control_point.write_value( + bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_DOWN, 0]) + ) + assert (await vcp_client.volume_state.read_value()) == (16, 1, 1) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy): + await vcp_client.volume_control_point.write_value( + bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_UP, 0]) + ) + assert (await vcp_client.volume_state.read_value()) == (48, 1, 1) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_unmute_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy): + await vcp_client.volume_control_point.write_value( + bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_DOWN, 0]) + ) + assert (await vcp_client.volume_state.read_value()) == (16, 0, 1) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_unmute_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy): + await vcp_client.volume_control_point.write_value( + bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_UP, 0]) + ) + assert (await vcp_client.volume_state.read_value()) == (48, 0, 1) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_set_absolute_volume(vcp_client: vcp.VolumeControlServiceProxy): + await vcp_client.volume_control_point.write_value( + bytes([vcp.VolumeControlPointOpcode.SET_ABSOLUTE_VOLUME, 0, 255]) + ) + assert (await vcp_client.volume_state.read_value()) == (255, 1, 1) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_mute(vcp_client: vcp.VolumeControlServiceProxy): + await vcp_client.volume_control_point.write_value( + bytes([vcp.VolumeControlPointOpcode.MUTE, 0]) + ) + assert (await vcp_client.volume_state.read_value()) == (32, 1, 0) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_unmute(vcp_client: vcp.VolumeControlServiceProxy): + await vcp_client.volume_control_point.write_value( + bytes([vcp.VolumeControlPointOpcode.UNMUTE, 0]) + ) + assert (await vcp_client.volume_state.read_value()) == (32, 0, 1)