bumble 0.0.203__py3-none-any.whl → 0.0.204__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.
@@ -0,0 +1,330 @@
1
+ # Copyright 2024 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
+ # -----------------------------------------------------------------------------
17
+ # Imports
18
+ # -----------------------------------------------------------------------------
19
+ import struct
20
+ from dataclasses import dataclass
21
+ from typing import Optional
22
+
23
+ from bumble.device import Connection
24
+ from bumble.att import ATT_Error
25
+ from bumble.gatt import (
26
+ Characteristic,
27
+ DelegatedCharacteristicAdapter,
28
+ TemplateService,
29
+ CharacteristicValue,
30
+ UTF8CharacteristicAdapter,
31
+ InvalidServiceError,
32
+ GATT_VOLUME_OFFSET_CONTROL_SERVICE,
33
+ GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
34
+ GATT_AUDIO_LOCATION_CHARACTERISTIC,
35
+ GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
36
+ GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
37
+ )
38
+ from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
39
+ from bumble.utils import OpenIntEnum
40
+ from bumble.profiles.bap import AudioLocation
41
+
42
+ # -----------------------------------------------------------------------------
43
+ # Constants
44
+ # -----------------------------------------------------------------------------
45
+
46
+ MIN_VOLUME_OFFSET = -255
47
+ MAX_VOLUME_OFFSET = 255
48
+ CHANGE_COUNTER_MAX_VALUE = 0xFF
49
+
50
+
51
+ class SetVolumeOffsetOpCode(OpenIntEnum):
52
+ SET_VOLUME_OFFSET = 0x01
53
+
54
+
55
+ class ErrorCode(OpenIntEnum):
56
+ """
57
+ See Volume Offset Control Service 1.6. Application error codes.
58
+ """
59
+
60
+ INVALID_CHANGE_COUNTER = 0x80
61
+ OPCODE_NOT_SUPPORTED = 0x81
62
+ VALUE_OUT_OF_RANGE = 0x82
63
+
64
+
65
+ # -----------------------------------------------------------------------------
66
+ @dataclass
67
+ class VolumeOffsetState:
68
+ volume_offset: int = 0
69
+ change_counter: int = 0
70
+ attribute_value: Optional[CharacteristicValue] = None
71
+
72
+ def __bytes__(self) -> bytes:
73
+ return struct.pack('<hB', self.volume_offset, self.change_counter)
74
+
75
+ @classmethod
76
+ def from_bytes(cls, data: bytes):
77
+ volume_offset, change_counter = struct.unpack('<hB', data)
78
+ return cls(volume_offset, change_counter)
79
+
80
+ def increment_change_counter(self) -> None:
81
+ self.change_counter = (self.change_counter + 1) % (CHANGE_COUNTER_MAX_VALUE + 1)
82
+
83
+ async def notify_subscribers_via_connection(self, connection: Connection) -> None:
84
+ assert self.attribute_value is not None
85
+ await connection.device.notify_subscribers(
86
+ attribute=self.attribute_value, value=bytes(self)
87
+ )
88
+
89
+ def on_read(self, _connection: Optional[Connection]) -> bytes:
90
+ return bytes(self)
91
+
92
+
93
+ @dataclass
94
+ class VocsAudioLocation:
95
+ audio_location: AudioLocation = AudioLocation.NOT_ALLOWED
96
+ attribute_value: Optional[CharacteristicValue] = None
97
+
98
+ def __bytes__(self) -> bytes:
99
+ return struct.pack('<I', self.audio_location)
100
+
101
+ @classmethod
102
+ def from_bytes(cls, data: bytes):
103
+ audio_location = AudioLocation(struct.unpack('<I', data)[0])
104
+ return cls(audio_location)
105
+
106
+ def on_read(self, _connection: Optional[Connection]) -> bytes:
107
+ return bytes(self)
108
+
109
+ async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
110
+ assert connection
111
+ assert self.attribute_value
112
+
113
+ self.audio_location = AudioLocation(int.from_bytes(value, 'little'))
114
+ await connection.device.notify_subscribers(
115
+ attribute=self.attribute_value, value=value
116
+ )
117
+
118
+
119
+ @dataclass
120
+ class VolumeOffsetControlPoint:
121
+ volume_offset_state: VolumeOffsetState
122
+
123
+ async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
124
+ assert connection
125
+
126
+ opcode = value[0]
127
+ if opcode != SetVolumeOffsetOpCode.SET_VOLUME_OFFSET:
128
+ raise ATT_Error(ErrorCode.OPCODE_NOT_SUPPORTED)
129
+
130
+ change_counter, volume_offset = struct.unpack('<Bh', value[1:])
131
+ await self._set_volume_offset(connection, change_counter, volume_offset)
132
+
133
+ async def _set_volume_offset(
134
+ self,
135
+ connection: Connection,
136
+ change_counter_operand: int,
137
+ volume_offset_operand: int,
138
+ ) -> None:
139
+ change_counter = self.volume_offset_state.change_counter
140
+
141
+ if change_counter != change_counter_operand:
142
+ raise ATT_Error(ErrorCode.INVALID_CHANGE_COUNTER)
143
+
144
+ if not MIN_VOLUME_OFFSET <= volume_offset_operand <= MAX_VOLUME_OFFSET:
145
+ raise ATT_Error(ErrorCode.VALUE_OUT_OF_RANGE)
146
+
147
+ self.volume_offset_state.volume_offset = volume_offset_operand
148
+ self.volume_offset_state.increment_change_counter()
149
+ await self.volume_offset_state.notify_subscribers_via_connection(connection)
150
+
151
+
152
+ @dataclass
153
+ class AudioOutputDescription:
154
+ audio_output_description: str = ''
155
+ attribute_value: Optional[CharacteristicValue] = None
156
+
157
+ @classmethod
158
+ def from_bytes(cls, data: bytes):
159
+ return cls(audio_output_description=data.decode('utf-8'))
160
+
161
+ def __bytes__(self) -> bytes:
162
+ return self.audio_output_description.encode('utf-8')
163
+
164
+ def on_read(self, _connection: Optional[Connection]) -> bytes:
165
+ return bytes(self)
166
+
167
+ async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
168
+ assert connection
169
+ assert self.attribute_value
170
+
171
+ self.audio_output_description = value.decode('utf-8')
172
+ await connection.device.notify_subscribers(
173
+ attribute=self.attribute_value, value=value
174
+ )
175
+
176
+
177
+ # -----------------------------------------------------------------------------
178
+ class VolumeOffsetControlService(TemplateService):
179
+ UUID = GATT_VOLUME_OFFSET_CONTROL_SERVICE
180
+
181
+ def __init__(
182
+ self,
183
+ volume_offset_state: Optional[VolumeOffsetState] = None,
184
+ audio_location: Optional[VocsAudioLocation] = None,
185
+ audio_output_description: Optional[AudioOutputDescription] = None,
186
+ ) -> None:
187
+
188
+ self.volume_offset_state = (
189
+ VolumeOffsetState() if volume_offset_state is None else volume_offset_state
190
+ )
191
+
192
+ self.audio_location = (
193
+ VocsAudioLocation() if audio_location is None else audio_location
194
+ )
195
+
196
+ self.audio_output_description = (
197
+ AudioOutputDescription()
198
+ if audio_output_description is None
199
+ else audio_output_description
200
+ )
201
+
202
+ self.volume_offset_control_point: VolumeOffsetControlPoint = (
203
+ VolumeOffsetControlPoint(self.volume_offset_state)
204
+ )
205
+
206
+ self.volume_offset_state_characteristic = DelegatedCharacteristicAdapter(
207
+ Characteristic(
208
+ uuid=GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
209
+ properties=(
210
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY
211
+ ),
212
+ permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
213
+ value=CharacteristicValue(read=self.volume_offset_state.on_read),
214
+ ),
215
+ encode=lambda value: bytes(value),
216
+ )
217
+
218
+ self.audio_location_characteristic = DelegatedCharacteristicAdapter(
219
+ Characteristic(
220
+ uuid=GATT_AUDIO_LOCATION_CHARACTERISTIC,
221
+ properties=(
222
+ Characteristic.Properties.READ
223
+ | Characteristic.Properties.NOTIFY
224
+ | Characteristic.Properties.WRITE_WITHOUT_RESPONSE
225
+ ),
226
+ permissions=(
227
+ Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
228
+ | Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
229
+ ),
230
+ value=CharacteristicValue(
231
+ read=self.audio_location.on_read,
232
+ write=self.audio_location.on_write,
233
+ ),
234
+ ),
235
+ encode=lambda value: bytes(value),
236
+ decode=VocsAudioLocation.from_bytes,
237
+ )
238
+ self.audio_location.attribute_value = self.audio_location_characteristic.value
239
+
240
+ self.volume_offset_control_point_characteristic = Characteristic(
241
+ uuid=GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
242
+ properties=Characteristic.Properties.WRITE,
243
+ permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
244
+ value=CharacteristicValue(write=self.volume_offset_control_point.on_write),
245
+ )
246
+
247
+ self.audio_output_description_characteristic = DelegatedCharacteristicAdapter(
248
+ Characteristic(
249
+ uuid=GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
250
+ properties=(
251
+ Characteristic.Properties.READ
252
+ | Characteristic.Properties.NOTIFY
253
+ | Characteristic.Properties.WRITE_WITHOUT_RESPONSE
254
+ ),
255
+ permissions=(
256
+ Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
257
+ | Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
258
+ ),
259
+ value=CharacteristicValue(
260
+ read=self.audio_output_description.on_read,
261
+ write=self.audio_output_description.on_write,
262
+ ),
263
+ )
264
+ )
265
+
266
+ self.audio_output_description.attribute_value = (
267
+ self.audio_output_description_characteristic.value
268
+ )
269
+
270
+ super().__init__(
271
+ characteristics=[
272
+ self.volume_offset_state_characteristic, # type: ignore
273
+ self.audio_location_characteristic, # type: ignore
274
+ self.volume_offset_control_point_characteristic, # type: ignore
275
+ self.audio_output_description_characteristic, # type: ignore
276
+ ],
277
+ primary=False,
278
+ )
279
+
280
+
281
+ # -----------------------------------------------------------------------------
282
+ # Client
283
+ # -----------------------------------------------------------------------------
284
+ class VolumeOffsetControlServiceProxy(ProfileServiceProxy):
285
+ SERVICE_CLASS = VolumeOffsetControlService
286
+
287
+ def __init__(self, service_proxy: ServiceProxy) -> None:
288
+ self.service_proxy = service_proxy
289
+
290
+ if not (
291
+ characteristics := service_proxy.get_characteristics_by_uuid(
292
+ GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC
293
+ )
294
+ ):
295
+ raise InvalidServiceError("Volume Offset State characteristic not found")
296
+ self.volume_offset_state = DelegatedCharacteristicAdapter(
297
+ characteristics[0], decode=VolumeOffsetState.from_bytes
298
+ )
299
+
300
+ if not (
301
+ characteristics := service_proxy.get_characteristics_by_uuid(
302
+ GATT_AUDIO_LOCATION_CHARACTERISTIC
303
+ )
304
+ ):
305
+ raise InvalidServiceError("Audio Location characteristic not found")
306
+ self.audio_location = DelegatedCharacteristicAdapter(
307
+ characteristics[0],
308
+ encode=lambda value: bytes(value),
309
+ decode=VocsAudioLocation.from_bytes,
310
+ )
311
+
312
+ if not (
313
+ characteristics := service_proxy.get_characteristics_by_uuid(
314
+ GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC
315
+ )
316
+ ):
317
+ raise InvalidServiceError(
318
+ "Volume Offset Control Point characteristic not found"
319
+ )
320
+ self.volume_offset_control_point = characteristics[0]
321
+
322
+ if not (
323
+ characteristics := service_proxy.get_characteristics_by_uuid(
324
+ GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC
325
+ )
326
+ ):
327
+ raise InvalidServiceError(
328
+ "Audio Output Description characteristic not found"
329
+ )
330
+ self.audio_output_description = UTF8CharacteristicAdapter(characteristics[0])
bumble/smp.py CHANGED
@@ -695,6 +695,7 @@ class Session:
695
695
  self.ltk_ediv = 0
696
696
  self.ltk_rand = bytes(8)
697
697
  self.link_key: Optional[bytes] = None
698
+ self.maximum_encryption_key_size: int = 0
698
699
  self.initiator_key_distribution: int = 0
699
700
  self.responder_key_distribution: int = 0
700
701
  self.peer_random_value: Optional[bytes] = None
@@ -741,6 +742,10 @@ class Session:
741
742
  else:
742
743
  self.pairing_result = None
743
744
 
745
+ self.maximum_encryption_key_size = (
746
+ pairing_config.delegate.maximum_encryption_key_size
747
+ )
748
+
744
749
  # Key Distribution (default values before negotiation)
745
750
  self.initiator_key_distribution = (
746
751
  pairing_config.delegate.local_initiator_key_distribution
@@ -993,7 +998,7 @@ class Session:
993
998
  io_capability=self.io_capability,
994
999
  oob_data_flag=self.oob_data_flag,
995
1000
  auth_req=self.auth_req,
996
- maximum_encryption_key_size=16,
1001
+ maximum_encryption_key_size=self.maximum_encryption_key_size,
997
1002
  initiator_key_distribution=self.initiator_key_distribution,
998
1003
  responder_key_distribution=self.responder_key_distribution,
999
1004
  )
@@ -1005,7 +1010,7 @@ class Session:
1005
1010
  io_capability=self.io_capability,
1006
1011
  oob_data_flag=self.oob_data_flag,
1007
1012
  auth_req=self.auth_req,
1008
- maximum_encryption_key_size=16,
1013
+ maximum_encryption_key_size=self.maximum_encryption_key_size,
1009
1014
  initiator_key_distribution=self.initiator_key_distribution,
1010
1015
  responder_key_distribution=self.responder_key_distribution,
1011
1016
  )
@@ -0,0 +1,130 @@
1
+ # Copyright 2024 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
+ # Imports
17
+ # -----------------------------------------------------------------------------
18
+ import logging
19
+ import pathlib
20
+ import urllib.request
21
+ import urllib.error
22
+
23
+ import click
24
+
25
+ from bumble.colors import color
26
+ from bumble.drivers import intel
27
+
28
+
29
+ # -----------------------------------------------------------------------------
30
+ # Logging
31
+ # -----------------------------------------------------------------------------
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # -----------------------------------------------------------------------------
36
+ # Constants
37
+ # -----------------------------------------------------------------------------
38
+ LINUX_KERNEL_GIT_SOURCE = "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/intel"
39
+
40
+
41
+ # -----------------------------------------------------------------------------
42
+ # Functions
43
+ # -----------------------------------------------------------------------------
44
+ def download_file(base_url, name):
45
+ url = f"{base_url}/{name}"
46
+ with urllib.request.urlopen(url) as file:
47
+ data = file.read()
48
+ print(f"Downloaded {name}: {len(data)} bytes")
49
+ return data
50
+
51
+
52
+ # -----------------------------------------------------------------------------
53
+ @click.command
54
+ @click.option(
55
+ "--output-dir",
56
+ default="",
57
+ help="Output directory where the files will be saved. Defaults to the OS-specific"
58
+ "app data dir, which the driver will check when trying to find firmware",
59
+ show_default=True,
60
+ )
61
+ @click.option(
62
+ "--source",
63
+ type=click.Choice(["linux-kernel"]),
64
+ default="linux-kernel",
65
+ show_default=True,
66
+ )
67
+ @click.option("--single", help="Only download a single image set, by its base name")
68
+ @click.option("--force", is_flag=True, help="Overwrite files if they already exist")
69
+ def main(output_dir, source, single, force):
70
+ """Download Intel firmware images and configs."""
71
+
72
+ # Check that the output dir exists
73
+ if output_dir == '':
74
+ output_dir = intel.intel_firmware_dir()
75
+ else:
76
+ output_dir = pathlib.Path(output_dir)
77
+ if not output_dir.is_dir():
78
+ print("Output dir does not exist or is not a directory")
79
+ return
80
+
81
+ base_url = {
82
+ "linux-kernel": LINUX_KERNEL_GIT_SOURCE,
83
+ }[source]
84
+
85
+ print("Downloading")
86
+ print(color("FROM:", "green"), base_url)
87
+ print(color("TO:", "green"), output_dir)
88
+
89
+ if single:
90
+ images = [(f"{single}.sfi", f"{single}.ddc")]
91
+ else:
92
+ images = [
93
+ (f"{base_name}.sfi", f"{base_name}.ddc")
94
+ for base_name in intel.INTEL_FW_IMAGE_NAMES
95
+ ]
96
+
97
+ for fw_name, config_name in images:
98
+ print(color("---", "yellow"))
99
+ fw_image_out = output_dir / fw_name
100
+ if not force and fw_image_out.exists():
101
+ print(color(f"{fw_image_out} already exists, skipping", "red"))
102
+ continue
103
+ if config_name:
104
+ config_image_out = output_dir / config_name
105
+ if not force and config_image_out.exists():
106
+ print(color("f{config_image_out} already exists, skipping", "red"))
107
+ continue
108
+
109
+ try:
110
+ fw_image = download_file(base_url, fw_name)
111
+ except urllib.error.HTTPError as error:
112
+ print(f"Failed to download {fw_name}: {error}")
113
+ continue
114
+
115
+ config_image = None
116
+ if config_name:
117
+ try:
118
+ config_image = download_file(base_url, config_name)
119
+ except urllib.error.HTTPError as error:
120
+ print(f"Failed to download {config_name}: {error}")
121
+ continue
122
+
123
+ fw_image_out.write_bytes(fw_image)
124
+ if config_image:
125
+ config_image_out.write_bytes(config_image)
126
+
127
+
128
+ # -----------------------------------------------------------------------------
129
+ if __name__ == '__main__':
130
+ main()
@@ -0,0 +1,154 @@
1
+ # Copyright 2024 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
+ # Imports
17
+ # -----------------------------------------------------------------------------
18
+ import logging
19
+ import asyncio
20
+ import os
21
+ from typing import Any, Optional
22
+
23
+ import click
24
+
25
+ from bumble.colors import color
26
+ from bumble import transport
27
+ from bumble.drivers import intel
28
+ from bumble.host import Host
29
+
30
+ # -----------------------------------------------------------------------------
31
+ # Logging
32
+ # -----------------------------------------------------------------------------
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # -----------------------------------------------------------------------------
37
+ def print_device_info(device_info: dict[intel.ValueType, Any]) -> None:
38
+ if (mode := device_info.get(intel.ValueType.CURRENT_MODE_OF_OPERATION)) is not None:
39
+ print(
40
+ color("MODE:", "yellow"),
41
+ mode.name,
42
+ )
43
+ print(color("DETAILS:", "yellow"))
44
+ for key, value in device_info.items():
45
+ print(f" {color(key.name, 'green')}: {value}")
46
+
47
+
48
+ # -----------------------------------------------------------------------------
49
+ async def get_driver(host: Host, force: bool) -> Optional[intel.Driver]:
50
+ # Create a driver
51
+ driver = await intel.Driver.for_host(host, force)
52
+ if driver is None:
53
+ print("Device does not appear to be an Intel device")
54
+ return None
55
+
56
+ return driver
57
+
58
+
59
+ # -----------------------------------------------------------------------------
60
+ async def do_info(usb_transport, force):
61
+ async with await transport.open_transport(usb_transport) as (
62
+ hci_source,
63
+ hci_sink,
64
+ ):
65
+ host = Host(hci_source, hci_sink)
66
+ driver = await get_driver(host, force)
67
+ if driver is None:
68
+ return
69
+
70
+ # Get and print the device info
71
+ print_device_info(await driver.read_device_info())
72
+
73
+
74
+ # -----------------------------------------------------------------------------
75
+ async def do_load(usb_transport: str, force: bool) -> None:
76
+ async with await transport.open_transport(usb_transport) as (
77
+ hci_source,
78
+ hci_sink,
79
+ ):
80
+ host = Host(hci_source, hci_sink)
81
+ driver = await get_driver(host, force)
82
+ if driver is None:
83
+ return
84
+
85
+ # Reboot in bootloader mode
86
+ await driver.load_firmware()
87
+
88
+ # Get and print the device info
89
+ print_device_info(await driver.read_device_info())
90
+
91
+
92
+ # -----------------------------------------------------------------------------
93
+ async def do_bootloader(usb_transport: str, force: bool) -> None:
94
+ async with await transport.open_transport(usb_transport) as (
95
+ hci_source,
96
+ hci_sink,
97
+ ):
98
+ host = Host(hci_source, hci_sink)
99
+ driver = await get_driver(host, force)
100
+ if driver is None:
101
+ return
102
+
103
+ # Reboot in bootloader mode
104
+ await driver.reboot_bootloader()
105
+
106
+
107
+ # -----------------------------------------------------------------------------
108
+ @click.group()
109
+ def main():
110
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
111
+
112
+
113
+ @main.command
114
+ @click.argument("usb_transport")
115
+ @click.option(
116
+ "--force",
117
+ is_flag=True,
118
+ default=False,
119
+ help="Try to get the device info even if the USB info doesn't match",
120
+ )
121
+ def info(usb_transport, force):
122
+ """Get the firmware info."""
123
+ asyncio.run(do_info(usb_transport, force))
124
+
125
+
126
+ @main.command
127
+ @click.argument("usb_transport")
128
+ @click.option(
129
+ "--force",
130
+ is_flag=True,
131
+ default=False,
132
+ help="Load even if the USB info doesn't match",
133
+ )
134
+ def load(usb_transport, force):
135
+ """Load a firmware image."""
136
+ asyncio.run(do_load(usb_transport, force))
137
+
138
+
139
+ @main.command
140
+ @click.argument("usb_transport")
141
+ @click.option(
142
+ "--force",
143
+ is_flag=True,
144
+ default=False,
145
+ help="Attempt to reboot event if the USB info doesn't match",
146
+ )
147
+ def bootloader(usb_transport, force):
148
+ """Reboot in bootloader mode."""
149
+ asyncio.run(do_bootloader(usb_transport, force))
150
+
151
+
152
+ # -----------------------------------------------------------------------------
153
+ if __name__ == '__main__':
154
+ main()
bumble/transport/usb.py CHANGED
@@ -149,7 +149,10 @@ async def open_usb_transport(spec: str) -> Transport:
149
149
 
150
150
  if status != usb1.TRANSFER_COMPLETED:
151
151
  logger.warning(
152
- color(f'!!! OUT transfer not completed: status={status}', 'red')
152
+ color(
153
+ f'!!! OUT transfer not completed: status={status}',
154
+ 'red',
155
+ )
153
156
  )
154
157
 
155
158
  async def process_queue(self):
@@ -275,7 +278,10 @@ async def open_usb_transport(spec: str) -> Transport:
275
278
  )
276
279
  else:
277
280
  logger.warning(
278
- color(f'!!! IN transfer not completed: status={status}', 'red')
281
+ color(
282
+ f'!!! IN[{packet_type}] transfer not completed: status={status}',
283
+ 'red',
284
+ )
279
285
  )
280
286
  self.loop.call_soon_threadsafe(self.on_transport_lost)
281
287