Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Gaming Audio Profile #606

Merged
merged 3 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bumble/gatt.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,13 @@
GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')

# Gaming Audio Service (GMAS)
GATT_GMAP_ROLE_CHARACTERISTIC = UUID.from_16_bits(0x2C00, 'GMAP Role')
GATT_UGG_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2C01, 'UGG Features')
GATT_UGT_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2C02, 'UGT Features')
GATT_BGS_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2C03, 'BGS Features')
GATT_BGR_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2C04, 'BGR Features')

# Hearing Access Service
GATT_HEARING_AID_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2BDA, 'Hearing Aid Features')
GATT_HEARING_AID_PRESET_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BDB, 'Hearing Aid Preset Control Point')
Expand Down
198 changes: 198 additions & 0 deletions bumble/profiles/gmap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Copyright 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.

"""LE Audio - Gaming Audio Profile"""

# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import struct
from typing import Optional

from bumble.gatt import (
TemplateService,
DelegatedCharacteristicAdapter,
Characteristic,
GATT_GAMING_AUDIO_SERVICE,
GATT_GMAP_ROLE_CHARACTERISTIC,
GATT_UGG_FEATURES_CHARACTERISTIC,
GATT_UGT_FEATURES_CHARACTERISTIC,
GATT_BGS_FEATURES_CHARACTERISTIC,
GATT_BGR_FEATURES_CHARACTERISTIC,
InvalidServiceError,
)
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
from enum import IntFlag


# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class GmapRole(IntFlag):
UNICAST_GAME_GATEWAY = 1 << 0
UNICAST_GAME_TERMINAL = 1 << 1
BROADCAST_GAME_SENDER = 1 << 2
BROADCAST_GAME_RECEIVER = 1 << 3


class UggFeatures(IntFlag):
UGG_MULTIPLEX = 1 << 0
UGG_96_KBPS_SOURCE = 1 << 1
UGG_MULTISINK = 1 << 2


class UgtFeatures(IntFlag):
UGT_SOURCE = 1 << 0
UGT_80_KBPS_SOURCE = 1 << 1
UGT_SINK = 1 << 2
UGT_64_KBPS_SINK = 1 << 3
UGT_MULTIPLEX = 1 << 4
UGT_MULTISINK = 1 << 5
UGT_MULTISOURCE = 1 << 6


class BgsFeatures(IntFlag):
BGS_96_KBPS = 1 << 0


class BgrFeatures(IntFlag):
BGR_MULTISINK = 1 << 0
BGR_MULTIPLEX = 1 << 1


# -----------------------------------------------------------------------------
# Server
# -----------------------------------------------------------------------------
class GamingAudioService(TemplateService):
UUID = GATT_GAMING_AUDIO_SERVICE

gmap_role: Characteristic
ugg_features: Optional[Characteristic] = None
ugt_features: Optional[Characteristic] = None
bgs_features: Optional[Characteristic] = None
bgr_features: Optional[Characteristic] = None

def __init__(
self,
gmap_role: GmapRole,
ugg_features: Optional[UggFeatures] = None,
ugt_features: Optional[UgtFeatures] = None,
bgs_features: Optional[BgsFeatures] = None,
bgr_features: Optional[BgrFeatures] = None,
) -> None:
characteristics = []

ugg_features = UggFeatures(0) if ugg_features is None else ugg_features
ugt_features = UgtFeatures(0) if ugt_features is None else ugt_features
bgs_features = BgsFeatures(0) if bgs_features is None else bgs_features
bgr_features = BgrFeatures(0) if bgr_features is None else bgr_features

self.gmap_role = Characteristic(
uuid=GATT_GMAP_ROLE_CHARACTERISTIC,
properties=Characteristic.Properties.READ,
permissions=Characteristic.Permissions.READABLE,
value=struct.pack('B', gmap_role),
)
characteristics.append(self.gmap_role)

if gmap_role & GmapRole.UNICAST_GAME_GATEWAY:
self.ugg_features = Characteristic(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You still need to set a value when parameters are None, otherwise the attribute will be missing.

Copy link
Contributor Author

@wpiet wpiet Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if in given configuration a characteristic should not exist in the service?

As per the GMAP specification, section 4.7 Service characteristics, Table 4.1. GMAS characteristics, depending on the GMAP Role the other 4 characteristics are either mandatory or excluded, thus I added the role check before adding those conditional characteristics.

Copy link
Collaborator

@zxzxwu zxzxwu Dec 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. It's not really related to Bluetooth, but from a readability perspective, you should always set a value for any attribute, especially when you annotate them as Optional, people will expect there is such an attribute even they are None.
Consider if you are using Java, such a pattern will probably be warned that you may access a member without initialization.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @zxzxwu , that would mean that we will have characteristics exposed which are not really needed.

Also from the testing point of view, I see value in having an option to create invalid test device which is missing some characteristics to verify proper error handling on the DUT.

Does it makes sense?

We also had an idea that some of the characteristic could be created as per spec (depends on the role) but in fact as it is now gives good test flexibility.

Other options would be to extend this constructor with a bool's which would tell if given characteristic should be created but this seems to be overkill.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to create a characteristic. What is needed here is

Suggested change
self.ugg_features = Characteristic(
self.ugg_features = Characteristic(
...
else:
self.ugg_features = None

Or set it like

class GamingAudioService(TemplateService):
    UUID = GATT_GAMING_AUDIO_SERVICE

    gmap_role: Characteristic
    ugg_features: Optional[Characteristic] = None
    ugt_features: Optional[Characteristic] = None
    bgs_features: Optional[Characteristic] = None
    bgr_features: Optional[Characteristic] = None

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, now it is clear. I added the latter suggestion.

uuid=GATT_UGG_FEATURES_CHARACTERISTIC,
properties=Characteristic.Properties.READ,
permissions=Characteristic.Permissions.READABLE,
value=struct.pack('B', ugg_features),
)
characteristics.append(self.ugg_features)

if gmap_role & GmapRole.UNICAST_GAME_TERMINAL:
self.ugt_features = Characteristic(
uuid=GATT_UGT_FEATURES_CHARACTERISTIC,
properties=Characteristic.Properties.READ,
permissions=Characteristic.Permissions.READABLE,
value=struct.pack('B', ugt_features),
)
characteristics.append(self.ugt_features)

if gmap_role & GmapRole.BROADCAST_GAME_SENDER:
self.bgs_features = Characteristic(
uuid=GATT_BGS_FEATURES_CHARACTERISTIC,
properties=Characteristic.Properties.READ,
permissions=Characteristic.Permissions.READABLE,
value=struct.pack('B', bgs_features),
)
characteristics.append(self.bgs_features)

if gmap_role & GmapRole.BROADCAST_GAME_RECEIVER:
self.bgr_features = Characteristic(
uuid=GATT_BGR_FEATURES_CHARACTERISTIC,
properties=Characteristic.Properties.READ,
permissions=Characteristic.Permissions.READABLE,
value=struct.pack('B', bgr_features),
)
characteristics.append(self.bgr_features)

super().__init__(characteristics)


# -----------------------------------------------------------------------------
# Client
# -----------------------------------------------------------------------------
class GamingAudioServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = GamingAudioService

def __init__(self, service_proxy: ServiceProxy) -> None:
self.service_proxy = service_proxy

if not (
characteristics := service_proxy.get_characteristics_by_uuid(
GATT_GMAP_ROLE_CHARACTERISTIC
)
):
raise InvalidServiceError("GMAP Role Characteristic not found")
self.gmap_role = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
decode=lambda value: GmapRole(value[0]),
)

if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_UGG_FEATURES_CHARACTERISTIC
):
self.ugg_features = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
decode=lambda value: UggFeatures(value[0]),
)

if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_UGT_FEATURES_CHARACTERISTIC
):
self.ugt_features = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
decode=lambda value: UgtFeatures(value[0]),
)

if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_BGS_FEATURES_CHARACTERISTIC
):
self.bgs_features = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
decode=lambda value: BgsFeatures(value[0]),
)

if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_BGR_FEATURES_CHARACTERISTIC
):
self.bgr_features = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
decode=lambda value: BgrFeatures(value[0]),
)
84 changes: 84 additions & 0 deletions tests/gmap_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright 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 pytest
import pytest_asyncio

from bumble import device
from bumble.profiles.gmap import (
GamingAudioService,
GamingAudioServiceProxy,
GmapRole,
UggFeatures,
UgtFeatures,
BgrFeatures,
BgsFeatures,
)

from .test_utils import TwoDevices

# -----------------------------------------------------------------------------
# Tests
# -----------------------------------------------------------------------------
gmas_service = GamingAudioService(
gmap_role=GmapRole.UNICAST_GAME_GATEWAY
| GmapRole.UNICAST_GAME_TERMINAL
| GmapRole.BROADCAST_GAME_RECEIVER
| GmapRole.BROADCAST_GAME_SENDER,
ugg_features=UggFeatures.UGG_MULTISINK,
ugt_features=UgtFeatures.UGT_SOURCE,
bgr_features=BgrFeatures.BGR_MULTISINK,
bgs_features=BgsFeatures.BGS_96_KBPS,
)


@pytest_asyncio.fixture
async def gmap_client():
devices = TwoDevices()
devices[0].add_service(gmas_service)

await devices.setup_connection()

assert devices.connections[0]
assert devices.connections[1]

devices.connections[0].encryption = 1
devices.connections[1].encryption = 1

peer = device.Peer(devices.connections[1])

gmap_client = await peer.discover_service_and_create_proxy(GamingAudioServiceProxy)

assert gmap_client
yield gmap_client


# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_init_service(gmap_client: GamingAudioServiceProxy):
assert (
await gmap_client.gmap_role.read_value()
== GmapRole.UNICAST_GAME_GATEWAY
| GmapRole.UNICAST_GAME_TERMINAL
| GmapRole.BROADCAST_GAME_RECEIVER
| GmapRole.BROADCAST_GAME_SENDER
)
assert await gmap_client.ugg_features.read_value() == UggFeatures.UGG_MULTISINK
assert await gmap_client.ugt_features.read_value() == UgtFeatures.UGT_SOURCE
assert await gmap_client.bgr_features.read_value() == BgrFeatures.BGR_MULTISINK
assert await gmap_client.bgs_features.read_value() == BgsFeatures.BGS_96_KBPS
Loading