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.
Files changed (92) hide show
  1. bumble/_version.py +2 -2
  2. bumble/a2dp.py +6 -0
  3. bumble/apps/README.md +0 -3
  4. bumble/apps/auracast.py +14 -11
  5. bumble/apps/bench.py +482 -37
  6. bumble/apps/console.py +3 -3
  7. bumble/apps/controller_info.py +44 -12
  8. bumble/apps/controller_loopback.py +7 -7
  9. bumble/apps/controllers.py +4 -5
  10. bumble/apps/device_info.py +4 -5
  11. bumble/apps/gatt_dump.py +5 -5
  12. bumble/apps/gg_bridge.py +5 -5
  13. bumble/apps/hci_bridge.py +5 -4
  14. bumble/apps/l2cap_bridge.py +5 -5
  15. bumble/apps/lea_unicast/app.py +8 -3
  16. bumble/apps/pair.py +19 -11
  17. bumble/apps/pandora_server.py +2 -2
  18. bumble/apps/player/player.py +2 -3
  19. bumble/apps/rfcomm_bridge.py +3 -4
  20. bumble/apps/scan.py +4 -5
  21. bumble/apps/show.py +6 -4
  22. bumble/apps/speaker/speaker.html +1 -0
  23. bumble/apps/speaker/speaker.js +113 -62
  24. bumble/apps/speaker/speaker.py +123 -19
  25. bumble/apps/unbond.py +2 -3
  26. bumble/apps/usb_probe.py +2 -3
  27. bumble/at.py +4 -4
  28. bumble/att.py +2 -6
  29. bumble/avc.py +7 -7
  30. bumble/avctp.py +3 -3
  31. bumble/avdtp.py +16 -20
  32. bumble/avrcp.py +42 -54
  33. bumble/colors.py +2 -2
  34. bumble/controller.py +174 -45
  35. bumble/device.py +398 -182
  36. bumble/drivers/__init__.py +2 -2
  37. bumble/drivers/common.py +0 -2
  38. bumble/drivers/intel.py +37 -40
  39. bumble/drivers/rtk.py +28 -35
  40. bumble/gatt.py +4 -4
  41. bumble/gatt_adapters.py +4 -5
  42. bumble/gatt_client.py +26 -31
  43. bumble/gatt_server.py +7 -11
  44. bumble/hci.py +2648 -2909
  45. bumble/helpers.py +4 -5
  46. bumble/hfp.py +32 -37
  47. bumble/host.py +104 -35
  48. bumble/keys.py +5 -5
  49. bumble/l2cap.py +312 -409
  50. bumble/link.py +16 -280
  51. bumble/logging.py +65 -0
  52. bumble/pairing.py +23 -20
  53. bumble/pandora/__init__.py +2 -2
  54. bumble/pandora/config.py +2 -2
  55. bumble/pandora/device.py +6 -6
  56. bumble/pandora/host.py +27 -28
  57. bumble/pandora/l2cap.py +2 -2
  58. bumble/pandora/security.py +6 -6
  59. bumble/pandora/utils.py +3 -3
  60. bumble/profiles/ams.py +404 -0
  61. bumble/profiles/ascs.py +142 -131
  62. bumble/profiles/asha.py +2 -2
  63. bumble/profiles/bap.py +3 -4
  64. bumble/profiles/csip.py +2 -2
  65. bumble/profiles/device_information_service.py +2 -2
  66. bumble/profiles/gap.py +2 -2
  67. bumble/profiles/hap.py +34 -33
  68. bumble/profiles/le_audio.py +4 -4
  69. bumble/profiles/mcp.py +4 -4
  70. bumble/profiles/vcs.py +3 -5
  71. bumble/rfcomm.py +10 -10
  72. bumble/rtp.py +1 -2
  73. bumble/sdp.py +2 -2
  74. bumble/smp.py +62 -63
  75. bumble/tools/intel_util.py +3 -2
  76. bumble/tools/rtk_util.py +6 -5
  77. bumble/transport/__init__.py +2 -16
  78. bumble/transport/android_netsim.py +5 -5
  79. bumble/transport/common.py +4 -4
  80. bumble/transport/pyusb.py +2 -2
  81. bumble/utils.py +2 -5
  82. bumble/vendor/android/hci.py +118 -200
  83. bumble/vendor/zephyr/hci.py +32 -27
  84. {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/METADATA +4 -3
  85. {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/RECORD +89 -90
  86. {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/WHEEL +1 -1
  87. {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/entry_points.txt +0 -1
  88. bumble/apps/link_relay/__init__.py +0 -0
  89. bumble/apps/link_relay/link_relay.py +0 -289
  90. bumble/apps/link_relay/logging.yml +0 -21
  91. {bumble-0.0.212.dist-info → bumble-0.0.214.dist-info}/licenses/LICENSE +0 -0
  92. {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, Dict, List, Optional, Set, Tuple, cast
88
+ from typing import AsyncGenerator, Optional, cast
90
89
 
91
- PRIMARY_PHY_MAP: Dict[int, PrimaryPhy] = {
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: Dict[int, SecondaryPhy] = {
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: Dict[PrimaryPhy, Phy] = {
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: Dict[SecondaryPhy, Phy] = {
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: Dict[host_pb2.OwnAddressType, OwnAddressType] = {
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: Set[int]
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[Tuple[Address, int, AdvertisingData, int]]
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: List[Tuple[int, bytes]] = []
672
+ ad_structures: list[tuple[int, bytes]] = []
674
673
 
675
- uuids: List[str]
676
- datas: Dict[str, bytes]
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: List[UUID]
889
+ uuids: list[UUID]
891
890
  s: str
892
891
  i: int
893
- ij: Tuple[int, int]
894
- uuid_data: Tuple[UUID, bytes]
892
+ ij: tuple[int, int]
893
+ uuid_data: tuple[UUID, bytes]
895
894
  data: bytes
896
895
 
897
896
  if uuids := cast(
898
- List[UUID],
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
- List[UUID],
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
- List[UUID],
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
- List[UUID],
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
- List[UUID],
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
- List[UUID],
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
- Tuple[int, int],
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
- List[UUID],
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
- List[UUID],
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
- List[UUID],
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
- Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_16_BIT_UUID)
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
- Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_32_BIT_UUID)
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
- Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_128_BIT_UUID)
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, Dict, Optional, Union
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: Dict[bytes, ChannelContext] = {}
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()
@@ -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, Dict, Optional, Union
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.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
248
- hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
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.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
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: Dict[str, Callable[..., Union[None, Awaitable[None]]]] = {
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, Dict, Generator, MutableMapping, Optional, Tuple
25
+ from typing import Any, Generator, MutableMapping, Optional
26
26
 
27
- ADDRESS_TYPES: Dict[str, AddressType] = {
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
- ) -> Tuple[str, MutableMapping[str, Any]]:
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]}")