bumble 0.0.212__py3-none-any.whl → 0.0.214__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bumble/_version.py +2 -2
- bumble/a2dp.py +6 -0
- bumble/apps/README.md +0 -3
- bumble/apps/auracast.py +14 -11
- bumble/apps/bench.py +482 -37
- bumble/apps/console.py +3 -3
- bumble/apps/controller_info.py +44 -12
- bumble/apps/controller_loopback.py +7 -7
- bumble/apps/controllers.py +4 -5
- bumble/apps/device_info.py +4 -5
- bumble/apps/gatt_dump.py +5 -5
- bumble/apps/gg_bridge.py +5 -5
- bumble/apps/hci_bridge.py +5 -4
- bumble/apps/l2cap_bridge.py +5 -5
- bumble/apps/lea_unicast/app.py +8 -3
- bumble/apps/pair.py +19 -11
- bumble/apps/pandora_server.py +2 -2
- bumble/apps/player/player.py +2 -3
- bumble/apps/rfcomm_bridge.py +3 -4
- bumble/apps/scan.py +4 -5
- bumble/apps/show.py +6 -4
- bumble/apps/speaker/speaker.html +1 -0
- bumble/apps/speaker/speaker.js +113 -62
- bumble/apps/speaker/speaker.py +123 -19
- bumble/apps/unbond.py +2 -3
- bumble/apps/usb_probe.py +2 -3
- bumble/at.py +4 -4
- bumble/att.py +2 -6
- bumble/avc.py +7 -7
- bumble/avctp.py +3 -3
- bumble/avdtp.py +16 -20
- bumble/avrcp.py +42 -54
- bumble/colors.py +2 -2
- bumble/controller.py +174 -45
- bumble/device.py +398 -182
- bumble/drivers/__init__.py +2 -2
- bumble/drivers/common.py +0 -2
- bumble/drivers/intel.py +37 -40
- bumble/drivers/rtk.py +28 -35
- bumble/gatt.py +4 -4
- bumble/gatt_adapters.py +4 -5
- bumble/gatt_client.py +26 -31
- bumble/gatt_server.py +7 -11
- bumble/hci.py +2648 -2909
- bumble/helpers.py +4 -5
- bumble/hfp.py +32 -37
- bumble/host.py +104 -35
- bumble/keys.py +5 -5
- bumble/l2cap.py +312 -409
- bumble/link.py +16 -280
- bumble/logging.py +65 -0
- bumble/pairing.py +23 -20
- bumble/pandora/__init__.py +2 -2
- bumble/pandora/config.py +2 -2
- bumble/pandora/device.py +6 -6
- bumble/pandora/host.py +27 -28
- bumble/pandora/l2cap.py +2 -2
- bumble/pandora/security.py +6 -6
- bumble/pandora/utils.py +3 -3
- bumble/profiles/ams.py +404 -0
- bumble/profiles/ascs.py +142 -131
- bumble/profiles/asha.py +2 -2
- bumble/profiles/bap.py +3 -4
- bumble/profiles/csip.py +2 -2
- bumble/profiles/device_information_service.py +2 -2
- bumble/profiles/gap.py +2 -2
- bumble/profiles/hap.py +34 -33
- bumble/profiles/le_audio.py +4 -4
- bumble/profiles/mcp.py +4 -4
- bumble/profiles/vcs.py +3 -5
- bumble/rfcomm.py +10 -10
- bumble/rtp.py +1 -2
- bumble/sdp.py +2 -2
- bumble/smp.py +62 -63
- bumble/tools/intel_util.py +3 -2
- bumble/tools/rtk_util.py +6 -5
- bumble/transport/__init__.py +2 -16
- bumble/transport/android_netsim.py +5 -5
- bumble/transport/common.py +4 -4
- bumble/transport/pyusb.py +2 -2
- bumble/utils.py +2 -5
- bumble/vendor/android/hci.py +118 -200
- bumble/vendor/zephyr/hci.py +32 -27
- {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/METADATA +4 -3
- {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/RECORD +89 -90
- {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/WHEEL +1 -1
- {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/entry_points.txt +0 -1
- bumble/apps/link_relay/__init__.py +0 -0
- bumble/apps/link_relay/link_relay.py +0 -289
- bumble/apps/link_relay/logging.yml +0 -21
- {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/licenses/LICENSE +0 -0
- {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/top_level.txt +0 -0
bumble/pandora/host.py
CHANGED
|
@@ -73,7 +73,6 @@ from pandora.host_pb2 import (
|
|
|
73
73
|
ConnectResponse,
|
|
74
74
|
DataTypes,
|
|
75
75
|
DisconnectRequest,
|
|
76
|
-
DiscoverabilityMode,
|
|
77
76
|
InquiryResponse,
|
|
78
77
|
PrimaryPhy,
|
|
79
78
|
ReadLocalAddressResponse,
|
|
@@ -86,9 +85,9 @@ from pandora.host_pb2 import (
|
|
|
86
85
|
WaitConnectionResponse,
|
|
87
86
|
WaitDisconnectionRequest,
|
|
88
87
|
)
|
|
89
|
-
from typing import AsyncGenerator,
|
|
88
|
+
from typing import AsyncGenerator, Optional, cast
|
|
90
89
|
|
|
91
|
-
PRIMARY_PHY_MAP:
|
|
90
|
+
PRIMARY_PHY_MAP: dict[int, PrimaryPhy] = {
|
|
92
91
|
# Default value reported by Bumble for legacy Advertising reports.
|
|
93
92
|
# FIXME(uael): `None` might be a better value, but Bumble need to change accordingly.
|
|
94
93
|
0: PRIMARY_1M,
|
|
@@ -96,26 +95,26 @@ PRIMARY_PHY_MAP: Dict[int, PrimaryPhy] = {
|
|
|
96
95
|
3: PRIMARY_CODED,
|
|
97
96
|
}
|
|
98
97
|
|
|
99
|
-
SECONDARY_PHY_MAP:
|
|
98
|
+
SECONDARY_PHY_MAP: dict[int, SecondaryPhy] = {
|
|
100
99
|
0: SECONDARY_NONE,
|
|
101
100
|
1: SECONDARY_1M,
|
|
102
101
|
2: SECONDARY_2M,
|
|
103
102
|
3: SECONDARY_CODED,
|
|
104
103
|
}
|
|
105
104
|
|
|
106
|
-
PRIMARY_PHY_TO_BUMBLE_PHY_MAP:
|
|
105
|
+
PRIMARY_PHY_TO_BUMBLE_PHY_MAP: dict[PrimaryPhy, Phy] = {
|
|
107
106
|
PRIMARY_1M: Phy.LE_1M,
|
|
108
107
|
PRIMARY_CODED: Phy.LE_CODED,
|
|
109
108
|
}
|
|
110
109
|
|
|
111
|
-
SECONDARY_PHY_TO_BUMBLE_PHY_MAP:
|
|
110
|
+
SECONDARY_PHY_TO_BUMBLE_PHY_MAP: dict[SecondaryPhy, Phy] = {
|
|
112
111
|
SECONDARY_NONE: Phy.LE_1M,
|
|
113
112
|
SECONDARY_1M: Phy.LE_1M,
|
|
114
113
|
SECONDARY_2M: Phy.LE_2M,
|
|
115
114
|
SECONDARY_CODED: Phy.LE_CODED,
|
|
116
115
|
}
|
|
117
116
|
|
|
118
|
-
OWN_ADDRESS_MAP:
|
|
117
|
+
OWN_ADDRESS_MAP: dict[host_pb2.OwnAddressType, OwnAddressType] = {
|
|
119
118
|
host_pb2.PUBLIC: OwnAddressType.PUBLIC,
|
|
120
119
|
host_pb2.RANDOM: OwnAddressType.RANDOM,
|
|
121
120
|
host_pb2.RESOLVABLE_OR_PUBLIC: OwnAddressType.RESOLVABLE_OR_PUBLIC,
|
|
@@ -124,7 +123,7 @@ OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, OwnAddressType] = {
|
|
|
124
123
|
|
|
125
124
|
|
|
126
125
|
class HostService(HostServicer):
|
|
127
|
-
waited_connections:
|
|
126
|
+
waited_connections: set[int]
|
|
128
127
|
|
|
129
128
|
def __init__(
|
|
130
129
|
self, grpc_server: grpc.aio.Server, device: Device, config: Config
|
|
@@ -618,7 +617,7 @@ class HostService(HostServicer):
|
|
|
618
617
|
self.log.debug('Inquiry')
|
|
619
618
|
|
|
620
619
|
inquiry_queue: asyncio.Queue[
|
|
621
|
-
Optional[
|
|
620
|
+
Optional[tuple[Address, int, AdvertisingData, int]]
|
|
622
621
|
] = asyncio.Queue()
|
|
623
622
|
complete_handler = self.device.on(
|
|
624
623
|
self.device.EVENT_INQUIRY_COMPLETE, lambda: inquiry_queue.put_nowait(None)
|
|
@@ -670,10 +669,10 @@ class HostService(HostServicer):
|
|
|
670
669
|
return empty_pb2.Empty()
|
|
671
670
|
|
|
672
671
|
def unpack_data_types(self, dt: DataTypes) -> AdvertisingData:
|
|
673
|
-
ad_structures:
|
|
672
|
+
ad_structures: list[tuple[int, bytes]] = []
|
|
674
673
|
|
|
675
|
-
uuids:
|
|
676
|
-
datas:
|
|
674
|
+
uuids: list[str]
|
|
675
|
+
datas: dict[str, bytes]
|
|
677
676
|
|
|
678
677
|
def uuid128_from_str(uuid: str) -> bytes:
|
|
679
678
|
"""Decode a 128-bit uuid encoded as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
|
@@ -887,50 +886,50 @@ class HostService(HostServicer):
|
|
|
887
886
|
|
|
888
887
|
def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
|
|
889
888
|
dt = DataTypes()
|
|
890
|
-
uuids:
|
|
889
|
+
uuids: list[UUID]
|
|
891
890
|
s: str
|
|
892
891
|
i: int
|
|
893
|
-
ij:
|
|
894
|
-
uuid_data:
|
|
892
|
+
ij: tuple[int, int]
|
|
893
|
+
uuid_data: tuple[UUID, bytes]
|
|
895
894
|
data: bytes
|
|
896
895
|
|
|
897
896
|
if uuids := cast(
|
|
898
|
-
|
|
897
|
+
list[UUID],
|
|
899
898
|
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS),
|
|
900
899
|
):
|
|
901
900
|
dt.incomplete_service_class_uuids16.extend(
|
|
902
901
|
list(map(lambda x: x.to_hex_str('-'), uuids))
|
|
903
902
|
)
|
|
904
903
|
if uuids := cast(
|
|
905
|
-
|
|
904
|
+
list[UUID],
|
|
906
905
|
ad.get(AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS),
|
|
907
906
|
):
|
|
908
907
|
dt.complete_service_class_uuids16.extend(
|
|
909
908
|
list(map(lambda x: x.to_hex_str('-'), uuids))
|
|
910
909
|
)
|
|
911
910
|
if uuids := cast(
|
|
912
|
-
|
|
911
|
+
list[UUID],
|
|
913
912
|
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS),
|
|
914
913
|
):
|
|
915
914
|
dt.incomplete_service_class_uuids32.extend(
|
|
916
915
|
list(map(lambda x: x.to_hex_str('-'), uuids))
|
|
917
916
|
)
|
|
918
917
|
if uuids := cast(
|
|
919
|
-
|
|
918
|
+
list[UUID],
|
|
920
919
|
ad.get(AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS),
|
|
921
920
|
):
|
|
922
921
|
dt.complete_service_class_uuids32.extend(
|
|
923
922
|
list(map(lambda x: x.to_hex_str('-'), uuids))
|
|
924
923
|
)
|
|
925
924
|
if uuids := cast(
|
|
926
|
-
|
|
925
|
+
list[UUID],
|
|
927
926
|
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS),
|
|
928
927
|
):
|
|
929
928
|
dt.incomplete_service_class_uuids128.extend(
|
|
930
929
|
list(map(lambda x: x.to_hex_str('-'), uuids))
|
|
931
930
|
)
|
|
932
931
|
if uuids := cast(
|
|
933
|
-
|
|
932
|
+
list[UUID],
|
|
934
933
|
ad.get(AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS),
|
|
935
934
|
):
|
|
936
935
|
dt.complete_service_class_uuids128.extend(
|
|
@@ -945,42 +944,42 @@ class HostService(HostServicer):
|
|
|
945
944
|
if i := cast(int, ad.get(AdvertisingData.CLASS_OF_DEVICE)):
|
|
946
945
|
dt.class_of_device = i
|
|
947
946
|
if ij := cast(
|
|
948
|
-
|
|
947
|
+
tuple[int, int],
|
|
949
948
|
ad.get(AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE),
|
|
950
949
|
):
|
|
951
950
|
dt.peripheral_connection_interval_min = ij[0]
|
|
952
951
|
dt.peripheral_connection_interval_max = ij[1]
|
|
953
952
|
if uuids := cast(
|
|
954
|
-
|
|
953
|
+
list[UUID],
|
|
955
954
|
ad.get(AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS),
|
|
956
955
|
):
|
|
957
956
|
dt.service_solicitation_uuids16.extend(
|
|
958
957
|
list(map(lambda x: x.to_hex_str('-'), uuids))
|
|
959
958
|
)
|
|
960
959
|
if uuids := cast(
|
|
961
|
-
|
|
960
|
+
list[UUID],
|
|
962
961
|
ad.get(AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS),
|
|
963
962
|
):
|
|
964
963
|
dt.service_solicitation_uuids32.extend(
|
|
965
964
|
list(map(lambda x: x.to_hex_str('-'), uuids))
|
|
966
965
|
)
|
|
967
966
|
if uuids := cast(
|
|
968
|
-
|
|
967
|
+
list[UUID],
|
|
969
968
|
ad.get(AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS),
|
|
970
969
|
):
|
|
971
970
|
dt.service_solicitation_uuids128.extend(
|
|
972
971
|
list(map(lambda x: x.to_hex_str('-'), uuids))
|
|
973
972
|
)
|
|
974
973
|
if uuid_data := cast(
|
|
975
|
-
|
|
974
|
+
tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_16_BIT_UUID)
|
|
976
975
|
):
|
|
977
976
|
dt.service_data_uuid16[uuid_data[0].to_hex_str('-')] = uuid_data[1]
|
|
978
977
|
if uuid_data := cast(
|
|
979
|
-
|
|
978
|
+
tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_32_BIT_UUID)
|
|
980
979
|
):
|
|
981
980
|
dt.service_data_uuid32[uuid_data[0].to_hex_str('-')] = uuid_data[1]
|
|
982
981
|
if uuid_data := cast(
|
|
983
|
-
|
|
982
|
+
tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_128_BIT_UUID)
|
|
984
983
|
):
|
|
985
984
|
dt.service_data_uuid128[uuid_data[0].to_hex_str('-')] = uuid_data[1]
|
|
986
985
|
if data := cast(bytes, ad.get(AdvertisingData.PUBLIC_TARGET_ADDRESS, raw=True)):
|
bumble/pandora/l2cap.py
CHANGED
|
@@ -51,7 +51,7 @@ from pandora.l2cap_pb2 import ( # pytype: disable=pyi-error
|
|
|
51
51
|
WaitDisconnectionRequest,
|
|
52
52
|
WaitDisconnectionResponse,
|
|
53
53
|
)
|
|
54
|
-
from typing import AsyncGenerator,
|
|
54
|
+
from typing import AsyncGenerator, Optional, Union
|
|
55
55
|
from dataclasses import dataclass
|
|
56
56
|
|
|
57
57
|
L2capChannel = Union[ClassicChannel, LeCreditBasedChannel]
|
|
@@ -70,7 +70,7 @@ class L2CAPService(L2CAPServicer):
|
|
|
70
70
|
)
|
|
71
71
|
self.device = device
|
|
72
72
|
self.config = config
|
|
73
|
-
self.channels:
|
|
73
|
+
self.channels: dict[bytes, ChannelContext] = {}
|
|
74
74
|
|
|
75
75
|
def register_event(self, l2cap_channel: L2capChannel) -> ChannelContext:
|
|
76
76
|
close_future = asyncio.get_running_loop().create_future()
|
bumble/pandora/security.py
CHANGED
|
@@ -57,7 +57,7 @@ from pandora.security_pb2 import (
|
|
|
57
57
|
WaitSecurityRequest,
|
|
58
58
|
WaitSecurityResponse,
|
|
59
59
|
)
|
|
60
|
-
from typing import Any, AsyncGenerator, AsyncIterator, Callable,
|
|
60
|
+
from typing import Any, AsyncGenerator, AsyncIterator, Callable, Optional, Union
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
class PairingDelegate(BasePairingDelegate):
|
|
@@ -244,16 +244,16 @@ class SecurityService(SecurityServicer):
|
|
|
244
244
|
and connection.authenticated
|
|
245
245
|
and link_key_type
|
|
246
246
|
in (
|
|
247
|
-
hci.
|
|
248
|
-
hci.
|
|
247
|
+
hci.LinkKeyType.AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192,
|
|
248
|
+
hci.LinkKeyType.AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256,
|
|
249
249
|
)
|
|
250
250
|
)
|
|
251
251
|
if level == LEVEL4:
|
|
252
252
|
return (
|
|
253
|
-
connection.encryption == hci.HCI_Encryption_Change_Event.AES_CCM
|
|
253
|
+
connection.encryption == hci.HCI_Encryption_Change_Event.Enabled.AES_CCM
|
|
254
254
|
and connection.authenticated
|
|
255
255
|
and link_key_type
|
|
256
|
-
== hci.
|
|
256
|
+
== hci.LinkKeyType.AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256
|
|
257
257
|
)
|
|
258
258
|
raise InvalidArgumentError(f"Unexpected level {level}")
|
|
259
259
|
|
|
@@ -457,7 +457,7 @@ class SecurityService(SecurityServicer):
|
|
|
457
457
|
if self.need_pairing(connection, level):
|
|
458
458
|
pair_task = asyncio.create_task(connection.pair())
|
|
459
459
|
|
|
460
|
-
listeners:
|
|
460
|
+
listeners: dict[str, Callable[..., Union[None, Awaitable[None]]]] = {
|
|
461
461
|
'disconnection': set_failure('connection_died'),
|
|
462
462
|
'pairing_failure': set_failure('pairing_failure'),
|
|
463
463
|
'connection_authentication_failure': set_failure('authentication_failure'),
|
bumble/pandora/utils.py
CHANGED
|
@@ -22,9 +22,9 @@ import logging
|
|
|
22
22
|
from bumble.device import Device
|
|
23
23
|
from bumble.hci import Address, AddressType
|
|
24
24
|
from google.protobuf.message import Message # pytype: disable=pyi-error
|
|
25
|
-
from typing import Any,
|
|
25
|
+
from typing import Any, Generator, MutableMapping, Optional
|
|
26
26
|
|
|
27
|
-
ADDRESS_TYPES:
|
|
27
|
+
ADDRESS_TYPES: dict[str, AddressType] = {
|
|
28
28
|
"public": Address.PUBLIC_DEVICE_ADDRESS,
|
|
29
29
|
"random": Address.RANDOM_DEVICE_ADDRESS,
|
|
30
30
|
"public_identity": Address.PUBLIC_IDENTITY_ADDRESS,
|
|
@@ -43,7 +43,7 @@ class BumbleServerLoggerAdapter(logging.LoggerAdapter): # type: ignore
|
|
|
43
43
|
|
|
44
44
|
def process(
|
|
45
45
|
self, msg: str, kwargs: MutableMapping[str, Any]
|
|
46
|
-
) ->
|
|
46
|
+
) -> tuple[str, MutableMapping[str, Any]]:
|
|
47
47
|
assert self.extra
|
|
48
48
|
service_name = self.extra['service_name']
|
|
49
49
|
assert isinstance(service_name, str)
|
bumble/profiles/ams.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# Copyright 2025 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
|
+
Apple Media Service (AMS).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# -----------------------------------------------------------------------------
|
|
20
|
+
# Imports
|
|
21
|
+
# -----------------------------------------------------------------------------
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
import asyncio
|
|
24
|
+
import dataclasses
|
|
25
|
+
import enum
|
|
26
|
+
import logging
|
|
27
|
+
from typing import Optional, Iterable, Union
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
from bumble.device import Peer
|
|
31
|
+
from bumble.gatt import (
|
|
32
|
+
Characteristic,
|
|
33
|
+
GATT_AMS_SERVICE,
|
|
34
|
+
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
|
|
35
|
+
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
|
|
36
|
+
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
|
|
37
|
+
TemplateService,
|
|
38
|
+
)
|
|
39
|
+
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
|
40
|
+
from bumble import utils
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# -----------------------------------------------------------------------------
|
|
44
|
+
# Logging
|
|
45
|
+
# -----------------------------------------------------------------------------
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# -----------------------------------------------------------------------------
|
|
50
|
+
# Protocol
|
|
51
|
+
# -----------------------------------------------------------------------------
|
|
52
|
+
class RemoteCommandId(utils.OpenIntEnum):
|
|
53
|
+
PLAY = 0
|
|
54
|
+
PAUSE = 1
|
|
55
|
+
TOGGLE_PLAY_PAUSE = 2
|
|
56
|
+
NEXT_TRACK = 3
|
|
57
|
+
PREVIOUS_TRACK = 4
|
|
58
|
+
VOLUME_UP = 5
|
|
59
|
+
VOLUME_DOWN = 6
|
|
60
|
+
ADVANCE_REPEAT_MODE = 7
|
|
61
|
+
ADVANCE_SHUFFLE_MODE = 8
|
|
62
|
+
SKIP_FORWARD = 9
|
|
63
|
+
SKIP_BACKWARD = 10
|
|
64
|
+
LIKE_TRACK = 11
|
|
65
|
+
DISLIKE_TRACK = 12
|
|
66
|
+
BOOKMARK_TRACK = 13
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EntityId(utils.OpenIntEnum):
|
|
70
|
+
PLAYER = 0
|
|
71
|
+
QUEUE = 1
|
|
72
|
+
TRACK = 2
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ActionId(utils.OpenIntEnum):
|
|
76
|
+
POSITIVE = 0
|
|
77
|
+
NEGATIVE = 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class EntityUpdateFlags(enum.IntFlag):
|
|
81
|
+
TRUNCATED = 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PlayerAttributeId(utils.OpenIntEnum):
|
|
85
|
+
NAME = 0
|
|
86
|
+
PLAYBACK_INFO = 1
|
|
87
|
+
VOLUME = 2
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class QueueAttributeId(utils.OpenIntEnum):
|
|
91
|
+
INDEX = 0
|
|
92
|
+
COUNT = 1
|
|
93
|
+
SHUFFLE_MODE = 2
|
|
94
|
+
REPEAT_MODE = 3
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ShuffleMode(utils.OpenIntEnum):
|
|
98
|
+
OFF = 0
|
|
99
|
+
ONE = 1
|
|
100
|
+
ALL = 2
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RepeatMode(utils.OpenIntEnum):
|
|
104
|
+
OFF = 0
|
|
105
|
+
ONE = 1
|
|
106
|
+
ALL = 2
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TrackAttributeId(utils.OpenIntEnum):
|
|
110
|
+
ARTIST = 0
|
|
111
|
+
ALBUM = 1
|
|
112
|
+
TITLE = 2
|
|
113
|
+
DURATION = 3
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PlaybackState(utils.OpenIntEnum):
|
|
117
|
+
PAUSED = 0
|
|
118
|
+
PLAYING = 1
|
|
119
|
+
REWINDING = 2
|
|
120
|
+
FAST_FORWARDING = 3
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclasses.dataclass
|
|
124
|
+
class PlaybackInfo:
|
|
125
|
+
playback_state: PlaybackState = PlaybackState.PAUSED
|
|
126
|
+
playback_rate: float = 1.0
|
|
127
|
+
elapsed_time: float = 0.0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -----------------------------------------------------------------------------
|
|
131
|
+
# GATT Server-side
|
|
132
|
+
# -----------------------------------------------------------------------------
|
|
133
|
+
class Ams(TemplateService):
|
|
134
|
+
UUID = GATT_AMS_SERVICE
|
|
135
|
+
|
|
136
|
+
remote_command_characteristic: Characteristic
|
|
137
|
+
entity_update_characteristic: Characteristic
|
|
138
|
+
entity_attribute_characteristic: Characteristic
|
|
139
|
+
|
|
140
|
+
def __init__(self) -> None:
|
|
141
|
+
# TODO not the final implementation
|
|
142
|
+
self.remote_command_characteristic = Characteristic(
|
|
143
|
+
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
|
|
144
|
+
Characteristic.Properties.NOTIFY
|
|
145
|
+
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
|
146
|
+
Characteristic.Permissions.WRITEABLE,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# TODO not the final implementation
|
|
150
|
+
self.entity_update_characteristic = Characteristic(
|
|
151
|
+
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
|
|
152
|
+
Characteristic.Properties.NOTIFY | Characteristic.Properties.WRITE,
|
|
153
|
+
Characteristic.Permissions.WRITEABLE,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# TODO not the final implementation
|
|
157
|
+
self.entity_attribute_characteristic = Characteristic(
|
|
158
|
+
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
|
|
159
|
+
Characteristic.Properties.READ
|
|
160
|
+
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
|
161
|
+
Characteristic.Permissions.WRITEABLE | Characteristic.Permissions.READABLE,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
super().__init__(
|
|
165
|
+
[
|
|
166
|
+
self.remote_command_characteristic,
|
|
167
|
+
self.entity_update_characteristic,
|
|
168
|
+
self.entity_attribute_characteristic,
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# -----------------------------------------------------------------------------
|
|
174
|
+
# GATT Client-side
|
|
175
|
+
# -----------------------------------------------------------------------------
|
|
176
|
+
class AmsProxy(ProfileServiceProxy):
|
|
177
|
+
SERVICE_CLASS = Ams
|
|
178
|
+
|
|
179
|
+
# NOTE: these don't use adapters, because the format for write and notifications
|
|
180
|
+
# are different.
|
|
181
|
+
remote_command: CharacteristicProxy[bytes]
|
|
182
|
+
entity_update: CharacteristicProxy[bytes]
|
|
183
|
+
entity_attribute: CharacteristicProxy[bytes]
|
|
184
|
+
|
|
185
|
+
def __init__(self, service_proxy: ServiceProxy):
|
|
186
|
+
self.remote_command = service_proxy.get_required_characteristic_by_uuid(
|
|
187
|
+
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
self.entity_update = service_proxy.get_required_characteristic_by_uuid(
|
|
191
|
+
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
self.entity_attribute = service_proxy.get_required_characteristic_by_uuid(
|
|
195
|
+
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class AmsClient(utils.EventEmitter):
|
|
200
|
+
EVENT_SUPPORTED_COMMANDS = "supported_commands"
|
|
201
|
+
EVENT_PLAYER_NAME = "player_name"
|
|
202
|
+
EVENT_PLAYER_PLAYBACK_INFO = "player_playback_info"
|
|
203
|
+
EVENT_PLAYER_VOLUME = "player_volume"
|
|
204
|
+
EVENT_QUEUE_COUNT = "queue_count"
|
|
205
|
+
EVENT_QUEUE_INDEX = "queue_index"
|
|
206
|
+
EVENT_QUEUE_SHUFFLE_MODE = "queue_shuffle_mode"
|
|
207
|
+
EVENT_QUEUE_REPEAT_MODE = "queue_repeat_mode"
|
|
208
|
+
EVENT_TRACK_ARTIST = "track_artist"
|
|
209
|
+
EVENT_TRACK_ALBUM = "track_album"
|
|
210
|
+
EVENT_TRACK_TITLE = "track_title"
|
|
211
|
+
EVENT_TRACK_DURATION = "track_duration"
|
|
212
|
+
|
|
213
|
+
supported_commands: set[RemoteCommandId]
|
|
214
|
+
player_name: str = ""
|
|
215
|
+
player_playback_info: PlaybackInfo = PlaybackInfo(PlaybackState.PAUSED, 0.0, 0.0)
|
|
216
|
+
player_volume: float = 1.0
|
|
217
|
+
queue_count: int = 0
|
|
218
|
+
queue_index: int = 0
|
|
219
|
+
queue_shuffle_mode: ShuffleMode = ShuffleMode.OFF
|
|
220
|
+
queue_repeat_mode: RepeatMode = RepeatMode.OFF
|
|
221
|
+
track_artist: str = ""
|
|
222
|
+
track_album: str = ""
|
|
223
|
+
track_title: str = ""
|
|
224
|
+
track_duration: float = 0.0
|
|
225
|
+
|
|
226
|
+
def __init__(self, ams_proxy: AmsProxy) -> None:
|
|
227
|
+
super().__init__()
|
|
228
|
+
self._ams_proxy = ams_proxy
|
|
229
|
+
self._started = False
|
|
230
|
+
self._read_attribute_semaphore = asyncio.Semaphore()
|
|
231
|
+
self.supported_commands = set()
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
async def for_peer(cls, peer: Peer) -> Optional[AmsClient]:
|
|
235
|
+
ams_proxy = await peer.discover_service_and_create_proxy(AmsProxy)
|
|
236
|
+
if ams_proxy is None:
|
|
237
|
+
return None
|
|
238
|
+
return cls(ams_proxy)
|
|
239
|
+
|
|
240
|
+
async def start(self) -> None:
|
|
241
|
+
logger.debug("subscribing to remote command characteristic")
|
|
242
|
+
await self._ams_proxy.remote_command.subscribe(
|
|
243
|
+
self._on_remote_command_notification
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
logger.debug("subscribing to entity update characteristic")
|
|
247
|
+
await self._ams_proxy.entity_update.subscribe(
|
|
248
|
+
lambda data: utils.AsyncRunner.spawn(
|
|
249
|
+
self._on_entity_update_notification(data)
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
self._started = True
|
|
254
|
+
|
|
255
|
+
async def stop(self) -> None:
|
|
256
|
+
await self._ams_proxy.remote_command.unsubscribe(
|
|
257
|
+
self._on_remote_command_notification
|
|
258
|
+
)
|
|
259
|
+
await self._ams_proxy.entity_update.unsubscribe(
|
|
260
|
+
self._on_entity_update_notification
|
|
261
|
+
)
|
|
262
|
+
self._started = False
|
|
263
|
+
|
|
264
|
+
async def observe(
|
|
265
|
+
self,
|
|
266
|
+
entity: EntityId,
|
|
267
|
+
attributes: Iterable[
|
|
268
|
+
Union[PlayerAttributeId, QueueAttributeId, TrackAttributeId]
|
|
269
|
+
],
|
|
270
|
+
) -> None:
|
|
271
|
+
await self._ams_proxy.entity_update.write_value(
|
|
272
|
+
bytes([entity] + list(attributes)), with_response=True
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def command(self, command: RemoteCommandId) -> None:
|
|
276
|
+
await self._ams_proxy.remote_command.write_value(
|
|
277
|
+
bytes([command]), with_response=True
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
async def play(self) -> None:
|
|
281
|
+
await self.command(RemoteCommandId.PLAY)
|
|
282
|
+
|
|
283
|
+
async def pause(self) -> None:
|
|
284
|
+
await self.command(RemoteCommandId.PAUSE)
|
|
285
|
+
|
|
286
|
+
async def toggle_play_pause(self) -> None:
|
|
287
|
+
await self.command(RemoteCommandId.TOGGLE_PLAY_PAUSE)
|
|
288
|
+
|
|
289
|
+
async def next_track(self) -> None:
|
|
290
|
+
await self.command(RemoteCommandId.NEXT_TRACK)
|
|
291
|
+
|
|
292
|
+
async def previous_track(self) -> None:
|
|
293
|
+
await self.command(RemoteCommandId.PREVIOUS_TRACK)
|
|
294
|
+
|
|
295
|
+
async def volume_up(self) -> None:
|
|
296
|
+
await self.command(RemoteCommandId.VOLUME_UP)
|
|
297
|
+
|
|
298
|
+
async def volume_down(self) -> None:
|
|
299
|
+
await self.command(RemoteCommandId.VOLUME_DOWN)
|
|
300
|
+
|
|
301
|
+
async def advance_repeat_mode(self) -> None:
|
|
302
|
+
await self.command(RemoteCommandId.ADVANCE_REPEAT_MODE)
|
|
303
|
+
|
|
304
|
+
async def advance_shuffle_mode(self) -> None:
|
|
305
|
+
await self.command(RemoteCommandId.ADVANCE_SHUFFLE_MODE)
|
|
306
|
+
|
|
307
|
+
async def skip_forward(self) -> None:
|
|
308
|
+
await self.command(RemoteCommandId.SKIP_FORWARD)
|
|
309
|
+
|
|
310
|
+
async def skip_backward(self) -> None:
|
|
311
|
+
await self.command(RemoteCommandId.SKIP_BACKWARD)
|
|
312
|
+
|
|
313
|
+
async def like_track(self) -> None:
|
|
314
|
+
await self.command(RemoteCommandId.LIKE_TRACK)
|
|
315
|
+
|
|
316
|
+
async def dislike_track(self) -> None:
|
|
317
|
+
await self.command(RemoteCommandId.DISLIKE_TRACK)
|
|
318
|
+
|
|
319
|
+
async def bookmark_track(self) -> None:
|
|
320
|
+
await self.command(RemoteCommandId.BOOKMARK_TRACK)
|
|
321
|
+
|
|
322
|
+
def _on_remote_command_notification(self, data: bytes) -> None:
|
|
323
|
+
supported_commands = [RemoteCommandId(command) for command in data]
|
|
324
|
+
logger.debug(
|
|
325
|
+
f"supported commands: {[command.name for command in supported_commands]}"
|
|
326
|
+
)
|
|
327
|
+
for command in supported_commands:
|
|
328
|
+
self.supported_commands.add(command)
|
|
329
|
+
|
|
330
|
+
self.emit(self.EVENT_SUPPORTED_COMMANDS)
|
|
331
|
+
|
|
332
|
+
async def _on_entity_update_notification(self, data: bytes) -> None:
|
|
333
|
+
entity = EntityId(data[0])
|
|
334
|
+
flags = EntityUpdateFlags(data[2])
|
|
335
|
+
value = data[3:]
|
|
336
|
+
|
|
337
|
+
if flags & EntityUpdateFlags.TRUNCATED:
|
|
338
|
+
logger.debug("truncated attribute, fetching full value")
|
|
339
|
+
|
|
340
|
+
# Write the entity and attribute we're interested in
|
|
341
|
+
# (protected by a semaphore, so that we only read one attribute at a time)
|
|
342
|
+
async with self._read_attribute_semaphore:
|
|
343
|
+
await self._ams_proxy.entity_attribute.write_value(
|
|
344
|
+
data[:2], with_response=True
|
|
345
|
+
)
|
|
346
|
+
value = await self._ams_proxy.entity_attribute.read_value()
|
|
347
|
+
|
|
348
|
+
if entity == EntityId.PLAYER:
|
|
349
|
+
player_attribute = PlayerAttributeId(data[1])
|
|
350
|
+
if player_attribute == PlayerAttributeId.NAME:
|
|
351
|
+
self.player_name = value.decode()
|
|
352
|
+
self.emit(self.EVENT_PLAYER_NAME)
|
|
353
|
+
elif player_attribute == PlayerAttributeId.PLAYBACK_INFO:
|
|
354
|
+
playback_state_str, playback_rate_str, elapsed_time_str = (
|
|
355
|
+
value.decode().split(",")
|
|
356
|
+
)
|
|
357
|
+
self.player_playback_info = PlaybackInfo(
|
|
358
|
+
PlaybackState(int(playback_state_str)),
|
|
359
|
+
float(playback_rate_str),
|
|
360
|
+
float(elapsed_time_str),
|
|
361
|
+
)
|
|
362
|
+
self.emit(self.EVENT_PLAYER_PLAYBACK_INFO)
|
|
363
|
+
elif player_attribute == PlayerAttributeId.VOLUME:
|
|
364
|
+
self.player_volume = float(value.decode())
|
|
365
|
+
self.emit(self.EVENT_PLAYER_VOLUME)
|
|
366
|
+
else:
|
|
367
|
+
logger.warning(f"received unknown player attribute {player_attribute}")
|
|
368
|
+
|
|
369
|
+
elif entity == EntityId.QUEUE:
|
|
370
|
+
queue_attribute = QueueAttributeId(data[1])
|
|
371
|
+
if queue_attribute == QueueAttributeId.COUNT:
|
|
372
|
+
self.queue_count = int(value)
|
|
373
|
+
self.emit(self.EVENT_QUEUE_COUNT)
|
|
374
|
+
elif queue_attribute == QueueAttributeId.INDEX:
|
|
375
|
+
self.queue_index = int(value)
|
|
376
|
+
self.emit(self.EVENT_QUEUE_INDEX)
|
|
377
|
+
elif queue_attribute == QueueAttributeId.REPEAT_MODE:
|
|
378
|
+
self.queue_repeat_mode = RepeatMode(int(value))
|
|
379
|
+
self.emit(self.EVENT_QUEUE_REPEAT_MODE)
|
|
380
|
+
elif queue_attribute == QueueAttributeId.SHUFFLE_MODE:
|
|
381
|
+
self.queue_shuffle_mode = ShuffleMode(int(value))
|
|
382
|
+
self.emit(self.EVENT_QUEUE_SHUFFLE_MODE)
|
|
383
|
+
else:
|
|
384
|
+
logger.warning(f"received unknown queue attribute {queue_attribute}")
|
|
385
|
+
|
|
386
|
+
elif entity == EntityId.TRACK:
|
|
387
|
+
track_attribute = TrackAttributeId(data[1])
|
|
388
|
+
if track_attribute == TrackAttributeId.ARTIST:
|
|
389
|
+
self.track_artist = value.decode()
|
|
390
|
+
self.emit(self.EVENT_TRACK_ARTIST)
|
|
391
|
+
elif track_attribute == TrackAttributeId.ALBUM:
|
|
392
|
+
self.track_album = value.decode()
|
|
393
|
+
self.emit(self.EVENT_TRACK_ALBUM)
|
|
394
|
+
elif track_attribute == TrackAttributeId.TITLE:
|
|
395
|
+
self.track_title = value.decode()
|
|
396
|
+
self.emit(self.EVENT_TRACK_TITLE)
|
|
397
|
+
elif track_attribute == TrackAttributeId.DURATION:
|
|
398
|
+
self.track_duration = float(value.decode())
|
|
399
|
+
self.emit(self.EVENT_TRACK_DURATION)
|
|
400
|
+
else:
|
|
401
|
+
logger.warning(f"received unknown track attribute {track_attribute}")
|
|
402
|
+
|
|
403
|
+
else:
|
|
404
|
+
logger.warning(f"received unknown attribute ID {data[1]}")
|