bumble 0.0.213__py3-none-any.whl → 0.0.215__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 (123) hide show
  1. bumble/_version.py +16 -3
  2. bumble/a2dp.py +15 -16
  3. bumble/apps/auracast.py +14 -38
  4. bumble/apps/bench.py +10 -15
  5. bumble/apps/ble_rpa_tool.py +1 -0
  6. bumble/apps/console.py +22 -25
  7. bumble/apps/controller_info.py +20 -25
  8. bumble/apps/controller_loopback.py +6 -10
  9. bumble/apps/controllers.py +2 -3
  10. bumble/apps/device_info.py +4 -5
  11. bumble/apps/gatt_dump.py +3 -3
  12. bumble/apps/gg_bridge.py +7 -8
  13. bumble/apps/hci_bridge.py +4 -3
  14. bumble/apps/l2cap_bridge.py +5 -5
  15. bumble/apps/lea_unicast/app.py +16 -26
  16. bumble/apps/pair.py +30 -43
  17. bumble/apps/pandora_server.py +5 -4
  18. bumble/apps/player/player.py +20 -24
  19. bumble/apps/rfcomm_bridge.py +4 -10
  20. bumble/apps/scan.py +17 -8
  21. bumble/apps/show.py +4 -5
  22. bumble/apps/speaker/speaker.py +23 -27
  23. bumble/apps/unbond.py +3 -3
  24. bumble/apps/usb_probe.py +2 -4
  25. bumble/att.py +241 -246
  26. bumble/audio/io.py +5 -9
  27. bumble/avc.py +2 -2
  28. bumble/avctp.py +6 -7
  29. bumble/avdtp.py +19 -22
  30. bumble/avrcp.py +1097 -589
  31. bumble/codecs.py +2 -0
  32. bumble/controller.py +142 -35
  33. bumble/core.py +567 -248
  34. bumble/crypto/__init__.py +2 -2
  35. bumble/crypto/builtin.py +1 -1
  36. bumble/crypto/cryptography.py +2 -4
  37. bumble/data_types.py +1025 -0
  38. bumble/device.py +319 -267
  39. bumble/drivers/__init__.py +3 -2
  40. bumble/drivers/intel.py +3 -4
  41. bumble/drivers/rtk.py +26 -9
  42. bumble/gap.py +4 -4
  43. bumble/gatt.py +3 -2
  44. bumble/gatt_adapters.py +3 -11
  45. bumble/gatt_client.py +69 -81
  46. bumble/gatt_server.py +124 -124
  47. bumble/hci.py +114 -18
  48. bumble/helpers.py +19 -26
  49. bumble/hfp.py +10 -21
  50. bumble/hid.py +22 -16
  51. bumble/host.py +191 -103
  52. bumble/keys.py +5 -3
  53. bumble/l2cap.py +138 -104
  54. bumble/link.py +18 -19
  55. bumble/logging.py +65 -0
  56. bumble/pairing.py +7 -6
  57. bumble/pandora/__init__.py +9 -8
  58. bumble/pandora/config.py +3 -1
  59. bumble/pandora/device.py +3 -2
  60. bumble/pandora/host.py +38 -36
  61. bumble/pandora/l2cap.py +22 -21
  62. bumble/pandora/security.py +15 -15
  63. bumble/pandora/utils.py +5 -3
  64. bumble/profiles/aics.py +11 -11
  65. bumble/profiles/ams.py +403 -0
  66. bumble/profiles/ancs.py +6 -7
  67. bumble/profiles/ascs.py +14 -9
  68. bumble/profiles/asha.py +8 -12
  69. bumble/profiles/bap.py +11 -23
  70. bumble/profiles/bass.py +2 -7
  71. bumble/profiles/battery_service.py +3 -4
  72. bumble/profiles/cap.py +1 -2
  73. bumble/profiles/csip.py +2 -6
  74. bumble/profiles/device_information_service.py +2 -2
  75. bumble/profiles/gap.py +4 -4
  76. bumble/profiles/gatt_service.py +1 -4
  77. bumble/profiles/gmap.py +5 -5
  78. bumble/profiles/hap.py +62 -59
  79. bumble/profiles/heart_rate_service.py +5 -4
  80. bumble/profiles/le_audio.py +3 -1
  81. bumble/profiles/mcp.py +3 -7
  82. bumble/profiles/pacs.py +3 -6
  83. bumble/profiles/pbp.py +2 -0
  84. bumble/profiles/tmap.py +2 -3
  85. bumble/profiles/vcs.py +2 -8
  86. bumble/profiles/vocs.py +8 -8
  87. bumble/rfcomm.py +11 -14
  88. bumble/rtp.py +1 -0
  89. bumble/sdp.py +10 -8
  90. bumble/smp.py +151 -159
  91. bumble/snoop.py +5 -5
  92. bumble/tools/generate_company_id_list.py +1 -0
  93. bumble/tools/intel_fw_download.py +3 -3
  94. bumble/tools/intel_util.py +5 -4
  95. bumble/tools/rtk_fw_download.py +6 -3
  96. bumble/tools/rtk_util.py +26 -8
  97. bumble/transport/__init__.py +19 -15
  98. bumble/transport/android_emulator.py +8 -13
  99. bumble/transport/android_netsim.py +19 -18
  100. bumble/transport/common.py +12 -15
  101. bumble/transport/file.py +1 -1
  102. bumble/transport/hci_socket.py +4 -6
  103. bumble/transport/pty.py +5 -6
  104. bumble/transport/pyusb.py +7 -10
  105. bumble/transport/serial.py +2 -1
  106. bumble/transport/tcp_client.py +2 -2
  107. bumble/transport/tcp_server.py +11 -14
  108. bumble/transport/udp.py +3 -3
  109. bumble/transport/unix.py +67 -1
  110. bumble/transport/usb.py +6 -6
  111. bumble/transport/vhci.py +0 -1
  112. bumble/transport/ws_client.py +2 -1
  113. bumble/transport/ws_server.py +3 -2
  114. bumble/utils.py +20 -5
  115. bumble/vendor/android/hci.py +1 -2
  116. bumble/vendor/zephyr/hci.py +0 -1
  117. {bumble-0.0.213.dist-info → bumble-0.0.215.dist-info}/METADATA +4 -2
  118. bumble-0.0.215.dist-info/RECORD +183 -0
  119. bumble-0.0.213.dist-info/RECORD +0 -180
  120. {bumble-0.0.213.dist-info → bumble-0.0.215.dist-info}/WHEEL +0 -0
  121. {bumble-0.0.213.dist-info → bumble-0.0.215.dist-info}/entry_points.txt +0 -0
  122. {bumble-0.0.213.dist-info → bumble-0.0.215.dist-info}/licenses/LICENSE +0 -0
  123. {bumble-0.0.213.dist-info → bumble-0.0.215.dist-info}/top_level.txt +0 -0
bumble/pandora/host.py CHANGED
@@ -13,51 +13,23 @@
13
13
  # limitations under the License.
14
14
 
15
15
  from __future__ import annotations
16
+
16
17
  import asyncio
17
- import bumble.device
18
- import grpc
19
- import grpc.aio
20
18
  import logging
21
19
  import struct
20
+ from typing import AsyncGenerator, Optional, cast
22
21
 
23
- import bumble.utils
24
- from bumble.pandora import utils
25
- from bumble.pandora.config import Config
26
- from bumble.core import (
27
- PhysicalTransport,
28
- UUID,
29
- AdvertisingData,
30
- Appearance,
31
- ConnectionError,
32
- )
33
- from bumble.device import (
34
- DEVICE_DEFAULT_SCAN_INTERVAL,
35
- DEVICE_DEFAULT_SCAN_WINDOW,
36
- Advertisement,
37
- AdvertisingParameters,
38
- AdvertisingEventProperties,
39
- AdvertisingType,
40
- Device,
41
- )
42
- from bumble.gatt import Service
43
- from bumble.hci import (
44
- HCI_CONNECTION_ALREADY_EXISTS_ERROR,
45
- HCI_PAGE_TIMEOUT_ERROR,
46
- HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
47
- Address,
48
- Phy,
49
- Role,
50
- OwnAddressType,
51
- )
22
+ import grpc
23
+ import grpc.aio
52
24
  from google.protobuf import any_pb2 # pytype: disable=pyi-error
53
25
  from google.protobuf import empty_pb2 # pytype: disable=pyi-error
54
- from pandora.host_grpc_aio import HostServicer
55
26
  from pandora import host_pb2
27
+ from pandora.host_grpc_aio import HostServicer
56
28
  from pandora.host_pb2 import (
29
+ DISCOVERABLE_GENERAL,
30
+ DISCOVERABLE_LIMITED,
57
31
  NOT_CONNECTABLE,
58
32
  NOT_DISCOVERABLE,
59
- DISCOVERABLE_LIMITED,
60
- DISCOVERABLE_GENERAL,
61
33
  PRIMARY_1M,
62
34
  PRIMARY_CODED,
63
35
  SECONDARY_1M,
@@ -85,7 +57,37 @@ from pandora.host_pb2 import (
85
57
  WaitConnectionResponse,
86
58
  WaitDisconnectionRequest,
87
59
  )
88
- from typing import AsyncGenerator, Optional, cast
60
+
61
+ import bumble.device
62
+ import bumble.utils
63
+ from bumble.core import (
64
+ UUID,
65
+ AdvertisingData,
66
+ Appearance,
67
+ ConnectionError,
68
+ PhysicalTransport,
69
+ )
70
+ from bumble.device import (
71
+ DEVICE_DEFAULT_SCAN_INTERVAL,
72
+ DEVICE_DEFAULT_SCAN_WINDOW,
73
+ Advertisement,
74
+ AdvertisingEventProperties,
75
+ AdvertisingParameters,
76
+ AdvertisingType,
77
+ Device,
78
+ )
79
+ from bumble.gatt import Service
80
+ from bumble.hci import (
81
+ HCI_CONNECTION_ALREADY_EXISTS_ERROR,
82
+ HCI_PAGE_TIMEOUT_ERROR,
83
+ HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
84
+ Address,
85
+ OwnAddressType,
86
+ Phy,
87
+ Role,
88
+ )
89
+ from bumble.pandora import utils
90
+ from bumble.pandora.config import Config
89
91
 
90
92
  PRIMARY_PHY_MAP: dict[int, PrimaryPhy] = {
91
93
  # Default value reported by Bumble for legacy Advertising reports.
bumble/pandora/l2cap.py CHANGED
@@ -12,31 +12,21 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
  from __future__ import annotations
15
+
15
16
  import asyncio
16
- import grpc
17
17
  import json
18
18
  import logging
19
+ from asyncio import Future
20
+ from asyncio import Queue as AsyncQueue
21
+ from dataclasses import dataclass
22
+ from typing import AsyncGenerator, Optional, Union
19
23
 
20
- from asyncio import Queue as AsyncQueue, Future
21
-
22
- from bumble.pandora import utils
23
- from bumble.pandora.config import Config
24
- from bumble.core import OutOfResourcesError, InvalidArgumentError
25
- from bumble.device import Device
26
- from bumble.l2cap import (
27
- ClassicChannel,
28
- ClassicChannelServer,
29
- ClassicChannelSpec,
30
- LeCreditBasedChannel,
31
- LeCreditBasedChannelServer,
32
- LeCreditBasedChannelSpec,
33
- )
24
+ import grpc
34
25
  from google.protobuf import any_pb2, empty_pb2 # pytype: disable=pyi-error
35
26
  from pandora.l2cap_grpc_aio import L2CAPServicer # pytype: disable=pyi-error
36
- from pandora.l2cap_pb2 import ( # pytype: disable=pyi-error
37
- COMMAND_NOT_UNDERSTOOD,
38
- INVALID_CID_IN_REQUEST,
39
- Channel as PandoraChannel,
27
+ from pandora.l2cap_pb2 import COMMAND_NOT_UNDERSTOOD, INVALID_CID_IN_REQUEST
28
+ from pandora.l2cap_pb2 import Channel as PandoraChannel # pytype: disable=pyi-error
29
+ from pandora.l2cap_pb2 import (
40
30
  ConnectRequest,
41
31
  ConnectResponse,
42
32
  CreditBasedChannelRequest,
@@ -51,8 +41,19 @@ from pandora.l2cap_pb2 import ( # pytype: disable=pyi-error
51
41
  WaitDisconnectionRequest,
52
42
  WaitDisconnectionResponse,
53
43
  )
54
- from typing import AsyncGenerator, Optional, Union
55
- from dataclasses import dataclass
44
+
45
+ from bumble.core import InvalidArgumentError, OutOfResourcesError
46
+ from bumble.device import Device
47
+ from bumble.l2cap import (
48
+ ClassicChannel,
49
+ ClassicChannelServer,
50
+ ClassicChannelSpec,
51
+ LeCreditBasedChannel,
52
+ LeCreditBasedChannelServer,
53
+ LeCreditBasedChannelSpec,
54
+ )
55
+ from bumble.pandora import utils
56
+ from bumble.pandora.config import Config
56
57
 
57
58
  L2capChannel = Union[ClassicChannel, LeCreditBasedChannel]
58
59
 
@@ -13,24 +13,14 @@
13
13
  # limitations under the License.
14
14
 
15
15
  from __future__ import annotations
16
+
16
17
  import asyncio
17
18
  import contextlib
18
- from collections.abc import Awaitable
19
- import grpc
20
19
  import logging
20
+ from collections.abc import Awaitable
21
+ from typing import Any, AsyncGenerator, AsyncIterator, Callable, Optional, Union
21
22
 
22
- from bumble.pandora import utils
23
- from bumble.pandora.config import Config
24
- from bumble import hci
25
- from bumble.core import (
26
- PhysicalTransport,
27
- ProtocolError,
28
- InvalidArgumentError,
29
- )
30
- import bumble.utils
31
- from bumble.device import Connection as BumbleConnection, Device
32
- from bumble.hci import HCI_Error, Role
33
- from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
23
+ import grpc
34
24
  from google.protobuf import any_pb2 # pytype: disable=pyi-error
35
25
  from google.protobuf import empty_pb2 # pytype: disable=pyi-error
36
26
  from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
@@ -57,7 +47,17 @@ from pandora.security_pb2 import (
57
47
  WaitSecurityRequest,
58
48
  WaitSecurityResponse,
59
49
  )
60
- from typing import Any, AsyncGenerator, AsyncIterator, Callable, Optional, Union
50
+
51
+ import bumble.utils
52
+ from bumble import hci
53
+ from bumble.core import InvalidArgumentError, PhysicalTransport, ProtocolError
54
+ from bumble.device import Connection as BumbleConnection
55
+ from bumble.device import Device
56
+ from bumble.hci import HCI_Error, Role
57
+ from bumble.pairing import PairingConfig
58
+ from bumble.pairing import PairingDelegate as BasePairingDelegate
59
+ from bumble.pandora import utils
60
+ from bumble.pandora.config import Config
61
61
 
62
62
 
63
63
  class PairingDelegate(BasePairingDelegate):
bumble/pandora/utils.py CHANGED
@@ -13,16 +13,18 @@
13
13
  # limitations under the License.
14
14
 
15
15
  from __future__ import annotations
16
+
16
17
  import contextlib
17
18
  import functools
18
- import grpc
19
19
  import inspect
20
20
  import logging
21
+ from typing import Any, Generator, MutableMapping, Optional
22
+
23
+ import grpc
24
+ from google.protobuf.message import Message # pytype: disable=pyi-error
21
25
 
22
26
  from bumble.device import Device
23
27
  from bumble.hci import Address, AddressType
24
- from google.protobuf.message import Message # pytype: disable=pyi-error
25
- from typing import Any, Generator, MutableMapping, Optional
26
28
 
27
29
  ADDRESS_TYPES: dict[str, AddressType] = {
28
30
  "public": Address.PUBLIC_DEVICE_ADDRESS,
bumble/profiles/aics.py CHANGED
@@ -18,26 +18,27 @@
18
18
  # Imports
19
19
  # -----------------------------------------------------------------------------
20
20
  from __future__ import annotations
21
+
21
22
  import logging
22
23
  import struct
23
-
24
24
  from dataclasses import dataclass
25
25
  from typing import Optional
26
26
 
27
- from bumble.device import Connection
27
+ from bumble import utils
28
28
  from bumble.att import ATT_Error
29
+ from bumble.device import Connection
29
30
  from bumble.gatt import (
30
- Attribute,
31
- Characteristic,
32
- TemplateService,
33
- CharacteristicValue,
31
+ GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
34
32
  GATT_AUDIO_INPUT_CONTROL_SERVICE,
33
+ GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
35
34
  GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
36
- GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
37
- GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
38
35
  GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC,
39
- GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
40
- GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
36
+ GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
37
+ GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
38
+ Attribute,
39
+ Characteristic,
40
+ CharacteristicValue,
41
+ TemplateService,
41
42
  )
42
43
  from bumble.gatt_adapters import (
43
44
  CharacteristicProxy,
@@ -48,7 +49,6 @@ from bumble.gatt_adapters import (
48
49
  UTF8CharacteristicProxyAdapter,
49
50
  )
50
51
  from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
51
- from bumble import utils
52
52
 
53
53
  # -----------------------------------------------------------------------------
54
54
  # Logging
bumble/profiles/ams.py ADDED
@@ -0,0 +1,403 @@
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
+
24
+ import asyncio
25
+ import dataclasses
26
+ import enum
27
+ import logging
28
+ from typing import Iterable, Optional, Union
29
+
30
+ from bumble import utils
31
+ from bumble.device import Peer
32
+ from bumble.gatt import (
33
+ GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
34
+ GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
35
+ GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
36
+ GATT_AMS_SERVICE,
37
+ Characteristic,
38
+ TemplateService,
39
+ )
40
+ from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
41
+
42
+ # -----------------------------------------------------------------------------
43
+ # Logging
44
+ # -----------------------------------------------------------------------------
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ # -----------------------------------------------------------------------------
49
+ # Protocol
50
+ # -----------------------------------------------------------------------------
51
+ class RemoteCommandId(utils.OpenIntEnum):
52
+ PLAY = 0
53
+ PAUSE = 1
54
+ TOGGLE_PLAY_PAUSE = 2
55
+ NEXT_TRACK = 3
56
+ PREVIOUS_TRACK = 4
57
+ VOLUME_UP = 5
58
+ VOLUME_DOWN = 6
59
+ ADVANCE_REPEAT_MODE = 7
60
+ ADVANCE_SHUFFLE_MODE = 8
61
+ SKIP_FORWARD = 9
62
+ SKIP_BACKWARD = 10
63
+ LIKE_TRACK = 11
64
+ DISLIKE_TRACK = 12
65
+ BOOKMARK_TRACK = 13
66
+
67
+
68
+ class EntityId(utils.OpenIntEnum):
69
+ PLAYER = 0
70
+ QUEUE = 1
71
+ TRACK = 2
72
+
73
+
74
+ class ActionId(utils.OpenIntEnum):
75
+ POSITIVE = 0
76
+ NEGATIVE = 1
77
+
78
+
79
+ class EntityUpdateFlags(enum.IntFlag):
80
+ TRUNCATED = 1
81
+
82
+
83
+ class PlayerAttributeId(utils.OpenIntEnum):
84
+ NAME = 0
85
+ PLAYBACK_INFO = 1
86
+ VOLUME = 2
87
+
88
+
89
+ class QueueAttributeId(utils.OpenIntEnum):
90
+ INDEX = 0
91
+ COUNT = 1
92
+ SHUFFLE_MODE = 2
93
+ REPEAT_MODE = 3
94
+
95
+
96
+ class ShuffleMode(utils.OpenIntEnum):
97
+ OFF = 0
98
+ ONE = 1
99
+ ALL = 2
100
+
101
+
102
+ class RepeatMode(utils.OpenIntEnum):
103
+ OFF = 0
104
+ ONE = 1
105
+ ALL = 2
106
+
107
+
108
+ class TrackAttributeId(utils.OpenIntEnum):
109
+ ARTIST = 0
110
+ ALBUM = 1
111
+ TITLE = 2
112
+ DURATION = 3
113
+
114
+
115
+ class PlaybackState(utils.OpenIntEnum):
116
+ PAUSED = 0
117
+ PLAYING = 1
118
+ REWINDING = 2
119
+ FAST_FORWARDING = 3
120
+
121
+
122
+ @dataclasses.dataclass
123
+ class PlaybackInfo:
124
+ playback_state: PlaybackState = PlaybackState.PAUSED
125
+ playback_rate: float = 1.0
126
+ elapsed_time: float = 0.0
127
+
128
+
129
+ # -----------------------------------------------------------------------------
130
+ # GATT Server-side
131
+ # -----------------------------------------------------------------------------
132
+ class Ams(TemplateService):
133
+ UUID = GATT_AMS_SERVICE
134
+
135
+ remote_command_characteristic: Characteristic
136
+ entity_update_characteristic: Characteristic
137
+ entity_attribute_characteristic: Characteristic
138
+
139
+ def __init__(self) -> None:
140
+ # TODO not the final implementation
141
+ self.remote_command_characteristic = Characteristic(
142
+ GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
143
+ Characteristic.Properties.NOTIFY
144
+ | Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
145
+ Characteristic.Permissions.WRITEABLE,
146
+ )
147
+
148
+ # TODO not the final implementation
149
+ self.entity_update_characteristic = Characteristic(
150
+ GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
151
+ Characteristic.Properties.NOTIFY | Characteristic.Properties.WRITE,
152
+ Characteristic.Permissions.WRITEABLE,
153
+ )
154
+
155
+ # TODO not the final implementation
156
+ self.entity_attribute_characteristic = Characteristic(
157
+ GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
158
+ Characteristic.Properties.READ
159
+ | Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
160
+ Characteristic.Permissions.WRITEABLE | Characteristic.Permissions.READABLE,
161
+ )
162
+
163
+ super().__init__(
164
+ [
165
+ self.remote_command_characteristic,
166
+ self.entity_update_characteristic,
167
+ self.entity_attribute_characteristic,
168
+ ]
169
+ )
170
+
171
+
172
+ # -----------------------------------------------------------------------------
173
+ # GATT Client-side
174
+ # -----------------------------------------------------------------------------
175
+ class AmsProxy(ProfileServiceProxy):
176
+ SERVICE_CLASS = Ams
177
+
178
+ # NOTE: these don't use adapters, because the format for write and notifications
179
+ # are different.
180
+ remote_command: CharacteristicProxy[bytes]
181
+ entity_update: CharacteristicProxy[bytes]
182
+ entity_attribute: CharacteristicProxy[bytes]
183
+
184
+ def __init__(self, service_proxy: ServiceProxy):
185
+ self.remote_command = service_proxy.get_required_characteristic_by_uuid(
186
+ GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC
187
+ )
188
+
189
+ self.entity_update = service_proxy.get_required_characteristic_by_uuid(
190
+ GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC
191
+ )
192
+
193
+ self.entity_attribute = service_proxy.get_required_characteristic_by_uuid(
194
+ GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC
195
+ )
196
+
197
+
198
+ class AmsClient(utils.EventEmitter):
199
+ EVENT_SUPPORTED_COMMANDS = "supported_commands"
200
+ EVENT_PLAYER_NAME = "player_name"
201
+ EVENT_PLAYER_PLAYBACK_INFO = "player_playback_info"
202
+ EVENT_PLAYER_VOLUME = "player_volume"
203
+ EVENT_QUEUE_COUNT = "queue_count"
204
+ EVENT_QUEUE_INDEX = "queue_index"
205
+ EVENT_QUEUE_SHUFFLE_MODE = "queue_shuffle_mode"
206
+ EVENT_QUEUE_REPEAT_MODE = "queue_repeat_mode"
207
+ EVENT_TRACK_ARTIST = "track_artist"
208
+ EVENT_TRACK_ALBUM = "track_album"
209
+ EVENT_TRACK_TITLE = "track_title"
210
+ EVENT_TRACK_DURATION = "track_duration"
211
+
212
+ supported_commands: set[RemoteCommandId]
213
+ player_name: str = ""
214
+ player_playback_info: PlaybackInfo = PlaybackInfo(PlaybackState.PAUSED, 0.0, 0.0)
215
+ player_volume: float = 1.0
216
+ queue_count: int = 0
217
+ queue_index: int = 0
218
+ queue_shuffle_mode: ShuffleMode = ShuffleMode.OFF
219
+ queue_repeat_mode: RepeatMode = RepeatMode.OFF
220
+ track_artist: str = ""
221
+ track_album: str = ""
222
+ track_title: str = ""
223
+ track_duration: float = 0.0
224
+
225
+ def __init__(self, ams_proxy: AmsProxy) -> None:
226
+ super().__init__()
227
+ self._ams_proxy = ams_proxy
228
+ self._started = False
229
+ self._read_attribute_semaphore = asyncio.Semaphore()
230
+ self.supported_commands = set()
231
+
232
+ @classmethod
233
+ async def for_peer(cls, peer: Peer) -> Optional[AmsClient]:
234
+ ams_proxy = await peer.discover_service_and_create_proxy(AmsProxy)
235
+ if ams_proxy is None:
236
+ return None
237
+ return cls(ams_proxy)
238
+
239
+ async def start(self) -> None:
240
+ logger.debug("subscribing to remote command characteristic")
241
+ await self._ams_proxy.remote_command.subscribe(
242
+ self._on_remote_command_notification
243
+ )
244
+
245
+ logger.debug("subscribing to entity update characteristic")
246
+ await self._ams_proxy.entity_update.subscribe(
247
+ lambda data: utils.AsyncRunner.spawn(
248
+ self._on_entity_update_notification(data)
249
+ )
250
+ )
251
+
252
+ self._started = True
253
+
254
+ async def stop(self) -> None:
255
+ await self._ams_proxy.remote_command.unsubscribe(
256
+ self._on_remote_command_notification
257
+ )
258
+ await self._ams_proxy.entity_update.unsubscribe(
259
+ self._on_entity_update_notification
260
+ )
261
+ self._started = False
262
+
263
+ async def observe(
264
+ self,
265
+ entity: EntityId,
266
+ attributes: Iterable[
267
+ Union[PlayerAttributeId, QueueAttributeId, TrackAttributeId]
268
+ ],
269
+ ) -> None:
270
+ await self._ams_proxy.entity_update.write_value(
271
+ bytes([entity] + list(attributes)), with_response=True
272
+ )
273
+
274
+ async def command(self, command: RemoteCommandId) -> None:
275
+ await self._ams_proxy.remote_command.write_value(
276
+ bytes([command]), with_response=True
277
+ )
278
+
279
+ async def play(self) -> None:
280
+ await self.command(RemoteCommandId.PLAY)
281
+
282
+ async def pause(self) -> None:
283
+ await self.command(RemoteCommandId.PAUSE)
284
+
285
+ async def toggle_play_pause(self) -> None:
286
+ await self.command(RemoteCommandId.TOGGLE_PLAY_PAUSE)
287
+
288
+ async def next_track(self) -> None:
289
+ await self.command(RemoteCommandId.NEXT_TRACK)
290
+
291
+ async def previous_track(self) -> None:
292
+ await self.command(RemoteCommandId.PREVIOUS_TRACK)
293
+
294
+ async def volume_up(self) -> None:
295
+ await self.command(RemoteCommandId.VOLUME_UP)
296
+
297
+ async def volume_down(self) -> None:
298
+ await self.command(RemoteCommandId.VOLUME_DOWN)
299
+
300
+ async def advance_repeat_mode(self) -> None:
301
+ await self.command(RemoteCommandId.ADVANCE_REPEAT_MODE)
302
+
303
+ async def advance_shuffle_mode(self) -> None:
304
+ await self.command(RemoteCommandId.ADVANCE_SHUFFLE_MODE)
305
+
306
+ async def skip_forward(self) -> None:
307
+ await self.command(RemoteCommandId.SKIP_FORWARD)
308
+
309
+ async def skip_backward(self) -> None:
310
+ await self.command(RemoteCommandId.SKIP_BACKWARD)
311
+
312
+ async def like_track(self) -> None:
313
+ await self.command(RemoteCommandId.LIKE_TRACK)
314
+
315
+ async def dislike_track(self) -> None:
316
+ await self.command(RemoteCommandId.DISLIKE_TRACK)
317
+
318
+ async def bookmark_track(self) -> None:
319
+ await self.command(RemoteCommandId.BOOKMARK_TRACK)
320
+
321
+ def _on_remote_command_notification(self, data: bytes) -> None:
322
+ supported_commands = [RemoteCommandId(command) for command in data]
323
+ logger.debug(
324
+ f"supported commands: {[command.name for command in supported_commands]}"
325
+ )
326
+ for command in supported_commands:
327
+ self.supported_commands.add(command)
328
+
329
+ self.emit(self.EVENT_SUPPORTED_COMMANDS)
330
+
331
+ async def _on_entity_update_notification(self, data: bytes) -> None:
332
+ entity = EntityId(data[0])
333
+ flags = EntityUpdateFlags(data[2])
334
+ value = data[3:]
335
+
336
+ if flags & EntityUpdateFlags.TRUNCATED:
337
+ logger.debug("truncated attribute, fetching full value")
338
+
339
+ # Write the entity and attribute we're interested in
340
+ # (protected by a semaphore, so that we only read one attribute at a time)
341
+ async with self._read_attribute_semaphore:
342
+ await self._ams_proxy.entity_attribute.write_value(
343
+ data[:2], with_response=True
344
+ )
345
+ value = await self._ams_proxy.entity_attribute.read_value()
346
+
347
+ if entity == EntityId.PLAYER:
348
+ player_attribute = PlayerAttributeId(data[1])
349
+ if player_attribute == PlayerAttributeId.NAME:
350
+ self.player_name = value.decode()
351
+ self.emit(self.EVENT_PLAYER_NAME)
352
+ elif player_attribute == PlayerAttributeId.PLAYBACK_INFO:
353
+ playback_state_str, playback_rate_str, elapsed_time_str = (
354
+ value.decode().split(",")
355
+ )
356
+ self.player_playback_info = PlaybackInfo(
357
+ PlaybackState(int(playback_state_str)),
358
+ float(playback_rate_str),
359
+ float(elapsed_time_str),
360
+ )
361
+ self.emit(self.EVENT_PLAYER_PLAYBACK_INFO)
362
+ elif player_attribute == PlayerAttributeId.VOLUME:
363
+ self.player_volume = float(value.decode())
364
+ self.emit(self.EVENT_PLAYER_VOLUME)
365
+ else:
366
+ logger.warning(f"received unknown player attribute {player_attribute}")
367
+
368
+ elif entity == EntityId.QUEUE:
369
+ queue_attribute = QueueAttributeId(data[1])
370
+ if queue_attribute == QueueAttributeId.COUNT:
371
+ self.queue_count = int(value)
372
+ self.emit(self.EVENT_QUEUE_COUNT)
373
+ elif queue_attribute == QueueAttributeId.INDEX:
374
+ self.queue_index = int(value)
375
+ self.emit(self.EVENT_QUEUE_INDEX)
376
+ elif queue_attribute == QueueAttributeId.REPEAT_MODE:
377
+ self.queue_repeat_mode = RepeatMode(int(value))
378
+ self.emit(self.EVENT_QUEUE_REPEAT_MODE)
379
+ elif queue_attribute == QueueAttributeId.SHUFFLE_MODE:
380
+ self.queue_shuffle_mode = ShuffleMode(int(value))
381
+ self.emit(self.EVENT_QUEUE_SHUFFLE_MODE)
382
+ else:
383
+ logger.warning(f"received unknown queue attribute {queue_attribute}")
384
+
385
+ elif entity == EntityId.TRACK:
386
+ track_attribute = TrackAttributeId(data[1])
387
+ if track_attribute == TrackAttributeId.ARTIST:
388
+ self.track_artist = value.decode()
389
+ self.emit(self.EVENT_TRACK_ARTIST)
390
+ elif track_attribute == TrackAttributeId.ALBUM:
391
+ self.track_album = value.decode()
392
+ self.emit(self.EVENT_TRACK_ALBUM)
393
+ elif track_attribute == TrackAttributeId.TITLE:
394
+ self.track_title = value.decode()
395
+ self.emit(self.EVENT_TRACK_TITLE)
396
+ elif track_attribute == TrackAttributeId.DURATION:
397
+ self.track_duration = float(value.decode())
398
+ self.emit(self.EVENT_TRACK_DURATION)
399
+ else:
400
+ logger.warning(f"received unknown track attribute {track_attribute}")
401
+
402
+ else:
403
+ logger.warning(f"received unknown attribute ID {data[1]}")