diff options
author | Josh Wu <joshwu@google.com> | 2024-01-25 20:32:39 +0800 |
---|---|---|
committer | Josh Wu <joshwu@google.com> | 2024-01-31 10:04:30 +0800 |
commit | 3e8ce38eba8a6737e9104721f85029d1ad2130e5 (patch) | |
tree | 927309ef9ac6d2356ec06a7f049f784ee30247be | |
parent | 2920f05dae74777fd215e3b32c3b45409b17705e (diff) | |
download | bumble-3e8ce38eba8a6737e9104721f85029d1ad2130e5.tar.gz |
Add Volume Control Service
-rw-r--r-- | .vscode/settings.json | 2 | ||||
-rw-r--r-- | bumble/profiles/vcp.py | 228 | ||||
-rw-r--r-- | examples/leaudio.json | 1 | ||||
-rw-r--r-- | examples/leaudio_with_classic.json | 9 | ||||
-rw-r--r-- | examples/run_unicast_server.py | 8 | ||||
-rw-r--r-- | examples/run_vcp_renderer.py | 192 | ||||
-rw-r--r-- | examples/vcp_renderer.html | 103 | ||||
-rw-r--r-- | tests/vcp_test.py | 122 |
8 files changed, 661 insertions, 4 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json index 93e9ece..98b1d02 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 0000000..0788219 --- /dev/null +++ b/bumble/profiles/vcp.py @@ -0,0 +1,228 @@ +# 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 + +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: int) -> 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/leaudio.json b/examples/leaudio.json index c4c5a11..ad5f6c8 100644 --- a/examples/leaudio.json +++ b/examples/leaudio.json @@ -2,5 +2,6 @@ "name": "Bumble-LEA", "keystore": "JsonKeyStore", "address": "F0:F1:F2:F3:F4:FA", + "class_of_device": 2376708, "advertising_interval": 100 } diff --git a/examples/leaudio_with_classic.json b/examples/leaudio_with_classic.json new file mode 100644 index 0000000..8b0d593 --- /dev/null +++ b/examples/leaudio_with_classic.json @@ -0,0 +1,9 @@ +{ + "name": "Bumble-LEA", + "keystore": "JsonKeyStore", + "address": "F0:F1:F2:F3:F4:FA", + "classic_enabled": true, + "cis_enabled": true, + "class_of_device": 2376708, + "advertising_interval": 100 +} diff --git a/examples/run_unicast_server.py b/examples/run_unicast_server.py index 35e124d..7a63f51 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( '<HHHHHHI', 18, # Header length. - 24000 // 100, # Sampling Rate(/100Hz). + 48000 // 100, # Sampling Rate(/100Hz). 0, # Bitrate(unused). 1, # Channels. 10000 // 10, # Frame duration(/10us). diff --git a/examples/run_vcp_renderer.py b/examples/run_vcp_renderer.py new file mode 100644 index 0000000..b519bb6 --- /dev/null +++ b/examples/run_vcp_renderer.py @@ -0,0 +1,192 @@ +# 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 +# ----------------------------------------------------------------------------- +import asyncio +import logging +import sys +import os +import secrets +import websockets +import json + +from bumble.core import AdvertisingData +from bumble.device import Device +from bumble.hci import ( + CodecID, + CodingFormat, + OwnAddressType, + HCI_LE_Set_Extended_Advertising_Parameters_Command, +) +from bumble.profiles.bap import ( + CodecSpecificCapabilities, + ContextType, + AudioLocation, + SupportedSamplingFrequency, + SupportedFrameDuration, + PacRecord, + PublishedAudioCapabilitiesService, + AudioStreamControlService, +) +from bumble.profiles.cap import CommonAudioServiceService +from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType +from bumble.profiles.vcp import VolumeControlService + +from bumble.transport import open_transport_or_link + +from typing import Optional + + +def dumps_volume_state(volume_setting: int, muted: int, change_counter: int) -> 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 <config-file>' '<transport-spec-for-device>') + 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 + ) + + 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.PUBLIC, + 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 0000000..c438950 --- /dev/null +++ b/examples/vcp_renderer.html @@ -0,0 +1,103 @@ +<html data-bs-theme="dark"> + +<head> + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" + integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous"> + +</head> + +<body> + + <div class="container"> + + <label for="server-port" class="form-label">Server Port</label> + <div class="input-group mb-3"> + <input type="text" class="form-control" aria-label="Port Number" value="8989" id="port"> + <button class="btn btn-primary" type="button" onclick="connect()">Connect</button> + </div> + + <div class="row"> + <div class="col"> + <label for="volume_setting" class="form-label">Volume Setting</label> + <input type="range" class="form-range" min="0" max="255" id="volume_setting"> + </div> + <div class="col"> + <label for="change_counter" class="form-label">Change Counter</label> + <input type="range" class="form-range" min="0" max="255" id="change_counter"> + </div> + <div class="col"> + <div class="form-check form-switch"> + <input class="form-check-input" type="checkbox" role="switch" id="muted"> + <label class="form-check-label" for="muted">Muted</label> + </div> + </div> + </div> + + <button class="btn btn-primary" type="button" onclick="update_state()">Notify New Volume State</button> + + + <hr> + <div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2"> + <h3>Log</h3> + <code id="socketState"> + </code> + </div> + </div> + + <script> + let portInput = document.getElementById("port") + let volumeSetting = document.getElementById("volume_setting") + let muted = document.getElementById("muted") + let changeCounter = document.getElementById("change_counter") + let socket = null + + function connect() { + if (socket != null) { + return + } + socket = new WebSocket(`ws://localhost:${portInput.value}`); + socket.onopen = _ => { + socketState.innerText += 'OPEN\n' + } + socket.onclose = _ => { + socketState.innerText += 'CLOSED\n' + socket = null + } + socket.onerror = (error) => { + socketState.innerText += 'ERROR\n' + console.log(`ERROR: ${error}`) + } + socket.onmessage = (event) => { + socketState.innerText += `<- ${event.data}\n` + let volume_state = JSON.parse(event.data) + volumeSetting.value = volume_state.volume_setting + changeCounter.value = volume_state.change_counter + muted.checked = volume_state.muted ? true : false + } + } + + function send(message) { + if (socket && socket.readyState == WebSocket.OPEN) { + let jsonMessage = JSON.stringify(message) + socketState.innerText += `-> ${jsonMessage}\n` + socket.send(jsonMessage) + } else { + socketState.innerText += 'NOT CONNECTED\n' + } + } + + function update_state() { + send({ + volume_setting: parseInt(volumeSetting.value), + change_counter: parseInt(changeCounter.value), + muted: muted.checked ? 1 : 0 + }) + } + </script> + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" + integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" + crossorigin="anonymous"></script> + +</body> + +</html>
\ No newline at end of file diff --git a/tests/vcp_test.py b/tests/vcp_test.py new file mode 100644 index 0000000..5accdc4 --- /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) |