aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJosh Wu <joshwu@google.com>2024-01-25 20:32:39 +0800
committerJosh Wu <joshwu@google.com>2024-01-31 10:04:30 +0800
commit3e8ce38eba8a6737e9104721f85029d1ad2130e5 (patch)
tree927309ef9ac6d2356ec06a7f049f784ee30247be
parent2920f05dae74777fd215e3b32c3b45409b17705e (diff)
downloadbumble-3e8ce38eba8a6737e9104721f85029d1ad2130e5.tar.gz
Add Volume Control Service
-rw-r--r--.vscode/settings.json2
-rw-r--r--bumble/profiles/vcp.py228
-rw-r--r--examples/leaudio.json1
-rw-r--r--examples/leaudio_with_classic.json9
-rw-r--r--examples/run_unicast_server.py8
-rw-r--r--examples/run_vcp_renderer.py192
-rw-r--r--examples/vcp_renderer.html103
-rw-r--r--tests/vcp_test.py122
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)