sdwire 0.2.5__py3-none-any.whl → 0.3.0__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,856 @@
1
+ """Block device utilities for mapping USB devices to system block devices.
2
+
3
+ This module provides cross-platform functionality to map USB storage devices
4
+ to their corresponding block device paths (e.g., /dev/sda on Linux, /dev/disk2 on macOS).
5
+ It uses USB hierarchy and topology information for accurate device mapping.
6
+ """
7
+
8
+ import platform
9
+ import logging
10
+ import re
11
+ import subprocess
12
+ import json
13
+ import plistlib
14
+ from typing import Optional, List, Dict, Any
15
+ import usb.core
16
+ from sdwire.constants import (
17
+ SDWIRE3_VID,
18
+ SDWIRE3_PID,
19
+ SDWIREC_VID,
20
+ SDWIREC_PID,
21
+ USB_MASS_STORAGE_CLASS_ID,
22
+ )
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def map_usb_device_to_block_device(usb_device: usb.core.Device) -> Optional[str]:
28
+ """Map a USB device to its corresponding system block device path using USB hierarchy.
29
+
30
+ This function provides cross-platform mapping from USB devices to block devices
31
+ using USB topology information rather than serial numbers for more reliable detection.
32
+
33
+ Args:
34
+ usb_device: USB device object from pyusb representing the storage device
35
+
36
+ Returns:
37
+ Block device path (e.g., '/dev/sda' on Linux, '/dev/disk2' on macOS)
38
+ or None if no corresponding block device is found
39
+
40
+ Note:
41
+ - Uses USB bus, address, and port hierarchy for device correlation
42
+ - Handles both direct storage devices and hub-based topologies
43
+ - Falls back gracefully when USB information is not accessible
44
+ """
45
+ system = platform.system().lower()
46
+
47
+ if system == "linux":
48
+ return _map_usb_to_block_device_linux(usb_device)
49
+ elif system == "darwin": # macOS
50
+ return _map_usb_to_block_device_macos(usb_device)
51
+ else:
52
+ log.warning(f"Unsupported platform: {system}")
53
+ return None
54
+
55
+
56
+ def _get_usb_device_topology_key(usb_device: usb.core.Device) -> Optional[str]:
57
+ """Generate a topology-based key for USB device identification.
58
+
59
+ This creates a unique identifier based on USB bus, address, and port
60
+ hierarchy rather than serial numbers which may not be unique.
61
+
62
+ Args:
63
+ usb_device: USB device to generate key for
64
+
65
+ Returns:
66
+ Topology key string or None if device info is not accessible
67
+ """
68
+ try:
69
+ bus = getattr(usb_device, "bus", None)
70
+ address = getattr(usb_device, "address", None)
71
+
72
+ if bus is None or address is None:
73
+ return None
74
+
75
+ # Try to get port numbers for more specific topology info
76
+ try:
77
+ port_numbers = getattr(usb_device, "port_numbers", [])
78
+ if port_numbers:
79
+ port_path = ".".join(map(str, port_numbers))
80
+ return f"{bus}:{address}@{port_path}"
81
+ except (AttributeError, usb.core.USBError):
82
+ pass
83
+
84
+ # Fallback to bus:address
85
+ return f"{bus}:{address}"
86
+
87
+ except Exception as e:
88
+ log.debug(f"Error generating topology key: {e}")
89
+ return None
90
+
91
+
92
+ def _find_block_device_via_ioregistry_direct(
93
+ vendor_id: int,
94
+ product_id: int,
95
+ bus: Optional[int] = None,
96
+ address: Optional[int] = None,
97
+ ) -> Optional[str]:
98
+ """Find block device by searching IORegistry for USB mass storage devices.
99
+
100
+ This method searches for IOMedia objects that have USB mass storage
101
+ devices in their parent chain with matching vendor/product IDs.
102
+
103
+ Args:
104
+ vendor_id: USB vendor ID to match
105
+ product_id: USB product ID to match
106
+ bus: Optional USB bus number for more precise matching
107
+ address: Optional USB address for more precise matching
108
+
109
+ Returns:
110
+ Block device path (e.g., '/dev/disk14') or None if not found
111
+ """
112
+ try:
113
+ # Use system_profiler to find USB devices with media
114
+ result = subprocess.run(
115
+ ["system_profiler", "SPUSBDataType", "-xml"],
116
+ capture_output=True,
117
+ text=True,
118
+ timeout=10,
119
+ )
120
+
121
+ if result.returncode != 0:
122
+ return None
123
+
124
+ # Parse the plist output
125
+ data = plistlib.loads(result.stdout.encode())
126
+
127
+ # Search for USB devices with matching vendor/product ID that have media
128
+ def search_usb_tree(items):
129
+ for item in items:
130
+ if isinstance(item, dict):
131
+ # Check if this device matches
132
+ item_vendor = item.get("vendor_id", "")
133
+ item_product = item.get("product_id", "")
134
+
135
+ # Convert to int if in hex string format
136
+ try:
137
+ if isinstance(item_vendor, str):
138
+ # Handle format like "0x0bda (Realtek Semiconductor Corp.)"
139
+ vendor_match = re.search(r"0x([0-9a-fA-F]+)", item_vendor)
140
+ if vendor_match:
141
+ item_vendor = int(vendor_match.group(1), 16)
142
+ elif item_vendor.startswith("0x"):
143
+ item_vendor = int(item_vendor, 16)
144
+
145
+ if isinstance(item_product, str):
146
+ product_match = re.search(r"0x([0-9a-fA-F]+)", item_product)
147
+ if product_match:
148
+ item_product = int(product_match.group(1), 16)
149
+ elif item_product.startswith("0x"):
150
+ item_product = int(item_product, 16)
151
+
152
+ if item_vendor == vendor_id and item_product == product_id:
153
+ # If bus/address provided, verify they match
154
+ if bus is not None or address is not None:
155
+ location_id = item.get("location_id", "")
156
+ # Parse location ID format: "0x01142200 / 12"
157
+ location_match = re.search(r"/ (\d+)$", location_id)
158
+ if location_match:
159
+ device_address = int(location_match.group(1))
160
+ if (
161
+ address is not None
162
+ and device_address != address
163
+ ):
164
+ log.debug(
165
+ f"Address mismatch: looking for {address}, found {device_address}"
166
+ )
167
+ continue
168
+
169
+ # For more precise matching when multiple identical devices exist,
170
+ # we rely on the address from location_id since USB serial numbers
171
+ # are often identical for mass-produced devices
172
+
173
+ # Check if this device has media
174
+ media = item.get("Media", [])
175
+ if media:
176
+ for m in media:
177
+ bsd_name = m.get("bsd_name", "")
178
+ if bsd_name and re.match(r"^disk\d+$", bsd_name):
179
+ log.debug(
180
+ f"Found USB device media directly: {bsd_name} for device at address {address}"
181
+ )
182
+ return f"/dev/{bsd_name}"
183
+ except (ValueError, TypeError):
184
+ pass
185
+
186
+ # Search children
187
+ if "_items" in item:
188
+ result = search_usb_tree(item["_items"])
189
+ if result:
190
+ return result
191
+
192
+ return None
193
+
194
+ # Search through all USB buses
195
+ for bus in data:
196
+ if "_items" in bus:
197
+ result = search_usb_tree(bus["_items"])
198
+ if result:
199
+ return result
200
+
201
+ except Exception as e:
202
+ log.debug(f"Error in direct IORegistry search: {e}")
203
+
204
+ return None
205
+
206
+
207
+ def _map_usb_to_block_device_linux(usb_device: usb.core.Device) -> Optional[str]:
208
+ """Map USB device to block device on Linux using lsblk and USB topology.
209
+
210
+ This function uses lsblk to enumerate block devices and correlates them
211
+ with the USB device using bus and address information from sysfs.
212
+
213
+ Args:
214
+ usb_device: USB device object from pyusb
215
+
216
+ Returns:
217
+ Block device path (e.g., '/dev/sda') or None if not found
218
+ """
219
+ try:
220
+ # Get device topology info
221
+ device_key = _get_usb_device_topology_key(usb_device)
222
+ if not device_key:
223
+ log.debug("Could not generate topology key for USB device")
224
+ return None
225
+
226
+ # Use lsblk to get detailed block device information
227
+ result = subprocess.run(
228
+ ["lsblk", "-o", "NAME,TRAN,VENDOR,MODEL,SERIAL,SUBSYSTEMS", "-J"],
229
+ capture_output=True,
230
+ text=True,
231
+ timeout=10,
232
+ )
233
+
234
+ if result.returncode != 0:
235
+ log.debug("lsblk command failed")
236
+ return None
237
+
238
+ try:
239
+ data = json.loads(result.stdout)
240
+ blockdevices = data.get("blockdevices", [])
241
+
242
+ # Find block devices that match our USB device topology
243
+ for device in blockdevices:
244
+ if device.get("tran") != "usb":
245
+ continue
246
+
247
+ device_name = device.get("name")
248
+ if not device_name:
249
+ continue
250
+
251
+ # Check if this block device corresponds to our USB device
252
+ if _is_block_device_match_linux(device, usb_device, device_key):
253
+ log.debug(f"Found matching block device: {device_name}")
254
+ return f"/dev/{device_name}"
255
+
256
+ except (json.JSONDecodeError, KeyError) as e:
257
+ log.debug(f"Failed to parse lsblk output: {e}")
258
+
259
+ except Exception as e:
260
+ log.debug(f"Error mapping USB to block device on Linux: {e}")
261
+
262
+ return None
263
+
264
+
265
+ def _is_block_device_match_linux(
266
+ block_device: Dict[str, Any], usb_device: usb.core.Device, device_key: str
267
+ ) -> bool:
268
+ """Check if a block device matches the given USB device on Linux.
269
+
270
+ Uses sysfs to correlate block devices with USB devices through bus and
271
+ address information.
272
+
273
+ Args:
274
+ block_device: Block device info from lsblk
275
+ usb_device: USB device to match
276
+ device_key: Topology key for the USB device
277
+
278
+ Returns:
279
+ True if the block device corresponds to the USB device
280
+ """
281
+ try:
282
+ device_name = block_device.get("name")
283
+ if not device_name:
284
+ return False
285
+
286
+ # Try to read USB info from sysfs
287
+ sysfs_path = f"/sys/block/{device_name}"
288
+
289
+ # Follow the device link to find USB information
290
+ try:
291
+ result = subprocess.run(
292
+ ["readlink", "-f", f"{sysfs_path}/device"],
293
+ capture_output=True,
294
+ text=True,
295
+ timeout=5,
296
+ )
297
+
298
+ if result.returncode == 0:
299
+ device_path = result.stdout.strip()
300
+
301
+ # Extract bus and address from the device path
302
+ # Typical path: /sys/devices/pci.../usb1/1-2/1-2.3/...
303
+ # We look for patterns like "1-2.3" which indicate USB bus 1,
304
+ # port path 2.3
305
+ usb_match = re.search(
306
+ r"/usb(\d+)/.*/(\d+)-([0-9.]+):.*/host", device_path
307
+ )
308
+ if usb_match:
309
+ bus_num = int(usb_match.group(1))
310
+ port_path = usb_match.group(3)
311
+
312
+ # Check if the topology matches (bus and port path)
313
+ if (
314
+ device_key.startswith(f"{bus_num}:")
315
+ and f"@{port_path}" in device_key
316
+ ):
317
+ return True
318
+
319
+ except Exception as e:
320
+ log.debug(f"Error reading sysfs for {device_name}: {e}")
321
+ return False
322
+
323
+ except Exception as e:
324
+ log.debug(f"Error checking block device match: {e}")
325
+ return False
326
+
327
+
328
+ def _map_usb_to_block_device_macos(usb_device: usb.core.Device) -> Optional[str]:
329
+ """Map USB device to block device on macOS using system_profiler and diskutil.
330
+
331
+ This function uses macOS system tools to enumerate USB devices and find
332
+ corresponding disk devices using USB topology information.
333
+
334
+ Args:
335
+ usb_device: USB device object from pyusb
336
+
337
+ Returns:
338
+ Block device path (e.g., '/dev/disk2') or None if not found
339
+ """
340
+ try:
341
+ vendor_id = getattr(usb_device, "idVendor", 0)
342
+ product_id = getattr(usb_device, "idProduct", 0)
343
+ bus = getattr(usb_device, "bus", None)
344
+ address = getattr(usb_device, "address", None)
345
+
346
+ log.debug(
347
+ f"Searching for USB device: VID={vendor_id:04x}, PID={product_id:04x}, bus={bus}, addr={address}"
348
+ )
349
+
350
+ # Method 1: Direct IORegistry search for mass storage devices
351
+ block_device = _find_block_device_via_ioregistry_direct(
352
+ vendor_id, product_id, bus, address
353
+ )
354
+ if block_device:
355
+ return block_device
356
+
357
+ # Method 2: system_profiler approach
358
+ result = subprocess.run(
359
+ ["system_profiler", "SPUSBDataType", "-json"],
360
+ capture_output=True,
361
+ text=True,
362
+ timeout=15,
363
+ )
364
+
365
+ if result.returncode != 0:
366
+ return None
367
+
368
+ try:
369
+ data = json.loads(result.stdout)
370
+ usb_data = data.get("SPUSBDataType", [])
371
+
372
+ # Find our USB device in the tree and get its location info
373
+ location_id = _find_usb_device_location_macos(
374
+ usb_data, vendor_id, product_id
375
+ )
376
+ if not location_id:
377
+ log.debug(
378
+ "Could not find USB device location in system_profiler output"
379
+ )
380
+ return None
381
+
382
+ # Now find the corresponding disk device
383
+ return _find_disk_by_usb_location_macos(location_id, vendor_id, product_id)
384
+
385
+ except json.JSONDecodeError:
386
+ log.debug("Failed to parse system_profiler JSON output")
387
+
388
+ except Exception as e:
389
+ log.debug(f"Error finding block device on macOS: {e}")
390
+
391
+ return None
392
+
393
+
394
+ def _find_usb_device_location_macos(
395
+ usb_tree: List[Dict],
396
+ target_vid: int,
397
+ target_pid: int,
398
+ ) -> Optional[str]:
399
+ """Find USB device location ID in macOS system_profiler tree.
400
+
401
+ Args:
402
+ usb_tree: USB device tree from system_profiler
403
+ target_vid: Target vendor ID
404
+ target_pid: Target product ID
405
+
406
+ Returns:
407
+ Location ID string or None if not found
408
+ """
409
+
410
+ def search_tree(items: List[Dict], depth: int = 0) -> Optional[str]:
411
+ for item in items:
412
+ if isinstance(item, dict):
413
+ # Check if this item matches our device
414
+ vendor_id_str = item.get("vendor_id", "")
415
+ product_id_str = item.get("product_id", "")
416
+ location_id = item.get("location_id", "")
417
+
418
+ # Log for debugging
419
+ if vendor_id_str or product_id_str:
420
+ log.debug(
421
+ f"Checking USB device: vendor={vendor_id_str}, product={product_id_str}, location={location_id}"
422
+ )
423
+
424
+ try:
425
+ # Handle different formatting of vendor/product IDs
426
+ vendor_match = False
427
+ product_match = False
428
+
429
+ # Check for exact hex match or partial match
430
+ if vendor_id_str:
431
+ vendor_hex = f"0x{target_vid:04x}"
432
+ if (
433
+ vendor_hex.lower() in vendor_id_str.lower()
434
+ or str(target_vid) in vendor_id_str
435
+ or f"{target_vid:04x}" in vendor_id_str.lower()
436
+ ):
437
+ vendor_match = True
438
+
439
+ if product_id_str:
440
+ product_hex = f"0x{target_pid:04x}"
441
+ if (
442
+ product_hex.lower() in product_id_str.lower()
443
+ or str(target_pid) in product_id_str
444
+ or f"{target_pid:04x}" in product_id_str.lower()
445
+ ):
446
+ product_match = True
447
+
448
+ if vendor_match and product_match:
449
+ log.debug(
450
+ f"Found matching USB device at location {location_id}"
451
+ )
452
+ return location_id
453
+
454
+ except Exception as e:
455
+ log.debug(f"Error checking USB device match: {e}")
456
+
457
+ # Search children
458
+ children = item.get("_items", [])
459
+ if children:
460
+ result = search_tree(children, depth + 1)
461
+ if result:
462
+ return result
463
+
464
+ return None
465
+
466
+ return search_tree(usb_tree)
467
+
468
+
469
+ def _find_disk_by_usb_location_macos(
470
+ location_id: str, vendor_id: int, product_id: int
471
+ ) -> Optional[str]:
472
+ """Find disk device corresponding to USB location ID on macOS.
473
+
474
+ Args:
475
+ location_id: USB location ID from system_profiler
476
+ vendor_id: USB vendor ID for additional validation
477
+ product_id: USB product ID for additional validation
478
+
479
+ Returns:
480
+ Block device path (e.g., '/dev/disk2') or None if not found
481
+ """
482
+ try:
483
+ all_disks = _get_all_disks_macos()
484
+ if not all_disks:
485
+ return None
486
+
487
+ # Check each disk to see if it corresponds to our USB device
488
+ for disk in all_disks:
489
+ if not _is_valid_disk_name(disk):
490
+ continue
491
+
492
+ disk_data = _get_disk_info_macos(disk)
493
+ if not disk_data:
494
+ continue
495
+
496
+ if _is_matching_usb_disk_macos(disk_data, vendor_id, product_id):
497
+ return f"/dev/{disk}"
498
+
499
+ except Exception as e:
500
+ log.debug(f"Error finding disk by USB location on macOS: {e}")
501
+
502
+ return None
503
+
504
+
505
+ def _get_all_disks_macos() -> Optional[List[str]]:
506
+ """Get list of all disks on macOS."""
507
+ try:
508
+ result = subprocess.run(
509
+ ["diskutil", "list", "-plist"], capture_output=True, text=True, timeout=10
510
+ )
511
+
512
+ if result.returncode != 0:
513
+ return None
514
+
515
+ data = plistlib.loads(result.stdout.encode())
516
+ return data.get("AllDisks", [])
517
+
518
+ except Exception as e:
519
+ log.debug(f"Failed to get disk list on macOS: {e}")
520
+ return None
521
+
522
+
523
+ def _is_valid_disk_name(disk: str) -> bool:
524
+ """Check if disk name is valid (not a partition)."""
525
+ return disk.startswith("disk") and not disk.endswith("s1")
526
+
527
+
528
+ def _get_disk_info_macos(disk: str) -> Optional[Dict[str, Any]]:
529
+ """Get detailed information for a specific disk on macOS."""
530
+ try:
531
+ result = subprocess.run(
532
+ ["diskutil", "info", "-plist", disk],
533
+ capture_output=True,
534
+ text=True,
535
+ timeout=5,
536
+ )
537
+
538
+ if result.returncode != 0:
539
+ return None
540
+
541
+ return plistlib.loads(result.stdout.encode())
542
+
543
+ except Exception as e:
544
+ log.debug(f"Error getting disk info for {disk}: {e}")
545
+ return None
546
+
547
+
548
+ def _match_disk_via_ioregistry_macos(
549
+ disk_identifier: str, vendor_id: int, product_id: int
550
+ ) -> bool:
551
+ """Match disk to USB device using IORegistry parent chain.
552
+
553
+ Args:
554
+ disk_identifier: Disk identifier (e.g., 'disk14')
555
+ vendor_id: USB vendor ID to match
556
+ product_id: USB product ID to match
557
+
558
+ Returns:
559
+ True if the disk is connected through the specified USB device
560
+ """
561
+ try:
562
+ # First, get the IOMedia object for this disk
563
+ media_result = subprocess.run(
564
+ ["ioreg", "-c", "IOMedia", "-w0", "-r"],
565
+ capture_output=True,
566
+ text=True,
567
+ timeout=5,
568
+ )
569
+
570
+ if media_result.returncode != 0:
571
+ return False
572
+
573
+ # Find the specific disk in the output
574
+ lines = media_result.stdout.split("\n")
575
+ disk_found = False
576
+
577
+ for i, line in enumerate(lines):
578
+ if f'"BSD Name" = "{disk_identifier}"' in line:
579
+ disk_found = True
580
+ # Look backwards to find the IOMedia object path
581
+ for j in range(i, max(0, i - 10), -1):
582
+ if "class IOMedia" in lines[j] and "<" in lines[j]:
583
+ # Extract registry ID
584
+ match = re.search(r"id 0x([0-9a-fA-F]+)", lines[j])
585
+ if match:
586
+ # Registry ID found but not currently used
587
+ break
588
+ break
589
+
590
+ if not disk_found:
591
+ log.debug(f"Disk {disk_identifier} not found in IORegistry")
592
+ return False
593
+
594
+ # Now trace upwards to find USB device
595
+ # Use ioreg to get the full parent chain
596
+ parent_result = subprocess.run(
597
+ ["ioreg", "-w0", "-p", "IOService", "-t"],
598
+ capture_output=True,
599
+ text=True,
600
+ timeout=10,
601
+ )
602
+
603
+ if parent_result.returncode != 0:
604
+ return False
605
+
606
+ # Search for our disk and trace its parents
607
+ output = parent_result.stdout
608
+ lines = output.split("\n")
609
+
610
+ # Find the disk and work backwards to find USB device
611
+ for i, line in enumerate(lines):
612
+ if disk_identifier in line and "IOMedia" in line:
613
+ # Work backwards from this line to find USB device info
614
+ indent_level = len(line) - len(line.lstrip())
615
+
616
+ # Search upwards for USB device with less indentation
617
+ for j in range(i, max(0, i - 100), -1):
618
+ parent_line = lines[j]
619
+ parent_indent = len(parent_line) - len(parent_line.lstrip())
620
+
621
+ # If we find a USB device at a higher level (less indented)
622
+ if parent_indent < indent_level and (
623
+ "IOUSBHostDevice" in parent_line
624
+ or "IOUSBMassStorageDriver" in parent_line
625
+ ):
626
+ # Now check the next few lines for vendor/product IDs
627
+ for k in range(j, min(j + 20, len(lines))):
628
+ check_line = lines[k]
629
+ # Check indentation to ensure we're still in the same device
630
+ check_indent = len(check_line) - len(check_line.lstrip())
631
+ if check_indent <= parent_indent and k > j:
632
+ break
633
+
634
+ # Look for vendor and product IDs
635
+ vendor_match = re.search(
636
+ r'"idVendor"\s*=\s*0x([0-9a-fA-F]+)', check_line
637
+ )
638
+ product_match = re.search(
639
+ r'"idProduct"\s*=\s*0x([0-9a-fA-F]+)', check_line
640
+ )
641
+
642
+ if (
643
+ vendor_match
644
+ and int(vendor_match.group(1), 16) == vendor_id
645
+ ):
646
+ # Check for product ID in nearby lines
647
+ for m in range(k - 5, min(k + 5, len(lines))):
648
+ pm = re.search(
649
+ r'"idProduct"\s*=\s*0x([0-9a-fA-F]+)', lines[m]
650
+ )
651
+ if pm and int(pm.group(1), 16) == product_id:
652
+ log.debug(
653
+ f"Found matching USB device for {disk_identifier}: VID={vendor_id:04x} PID={product_id:04x}"
654
+ )
655
+ return True
656
+
657
+ except Exception as e:
658
+ log.debug(f"Error matching disk via IORegistry: {e}")
659
+
660
+ return False
661
+
662
+
663
+ def _is_matching_usb_disk_macos(
664
+ disk_data: Dict[str, Any], vendor_id: int, product_id: int
665
+ ) -> bool:
666
+ """Check if disk data matches the expected USB device."""
667
+ # Check if this is a USB device
668
+ bus_protocol = disk_data.get("BusProtocol", "")
669
+ if "USB" not in bus_protocol:
670
+ return False
671
+
672
+ # Try to match using IORegistry information
673
+ # First, check if we can find this disk in IORegistry and trace its USB parent
674
+ disk_identifier = disk_data.get("DeviceIdentifier", "")
675
+ if disk_identifier:
676
+ log.debug(
677
+ f"Checking disk {disk_identifier} against VID={vendor_id:04x} PID={product_id:04x}"
678
+ )
679
+ return _match_disk_via_ioregistry_macos(disk_identifier, vendor_id, product_id)
680
+
681
+ return False
682
+
683
+
684
+ def find_sibling_storage_device(
685
+ control_device: Optional[usb.core.Device],
686
+ ) -> Optional[usb.core.Device]:
687
+ """Find sibling mass storage device for SDWireC hub topology.
688
+
689
+ For SDWireC devices, the FTDI control chip and mass storage device are
690
+ siblings under the same USB hub. This function finds the storage sibling.
691
+
692
+ Args:
693
+ control_device: The FTDI control device
694
+
695
+ Returns:
696
+ USB device object for the sibling mass storage device, or None if not
697
+ found
698
+ """
699
+ if not control_device:
700
+ return None
701
+
702
+ try:
703
+ control_topology = _get_device_topology_info(control_device)
704
+ if control_topology is None:
705
+ return None
706
+
707
+ bus, control_ports = control_topology
708
+
709
+ # Find all devices on the same bus
710
+ all_devices = _get_devices_on_bus(bus)
711
+ if not all_devices:
712
+ return None
713
+
714
+ return _find_sibling_in_devices(control_device, control_ports, all_devices)
715
+
716
+ except Exception as e:
717
+ log.debug(f"Error finding sibling storage device: {e}")
718
+
719
+ return None
720
+
721
+
722
+ def _get_device_topology_info(device: usb.core.Device) -> Optional[tuple]:
723
+ """Get topology information for a USB device."""
724
+ try:
725
+ bus = getattr(device, "bus", None)
726
+ if bus is None:
727
+ return None
728
+
729
+ ports = getattr(device, "port_numbers", [])
730
+ if ports is None:
731
+ return None
732
+
733
+ if not isinstance(ports, (list, tuple)):
734
+ return None
735
+
736
+ if len(ports) < 2: # Need at least hub + device port
737
+ return None
738
+
739
+ return (bus, ports)
740
+
741
+ except (AttributeError, usb.core.USBError) as e:
742
+ log.debug(f"Could not get topology info for device: {e}")
743
+ return None
744
+
745
+
746
+ def _get_devices_on_bus(bus: int) -> Optional[List[usb.core.Device]]:
747
+ """Get all USB devices on a specific bus."""
748
+ try:
749
+ devices_iter = usb.core.find(find_all=True, bus=bus)
750
+ if devices_iter is None:
751
+ return None
752
+ # Filter to only include Device objects, not Configuration objects
753
+ devices = [dev for dev in devices_iter if isinstance(dev, usb.core.Device)]
754
+ return devices
755
+
756
+ except Exception as e:
757
+ log.debug(f"Error getting devices on bus {bus}: {e}")
758
+ return None
759
+
760
+
761
+ def _find_sibling_in_devices(
762
+ control_device: usb.core.Device,
763
+ control_ports: List[int],
764
+ all_devices: List[usb.core.Device],
765
+ ) -> Optional[usb.core.Device]:
766
+ """Find sibling device among candidate devices."""
767
+ for candidate in all_devices:
768
+ if candidate == control_device:
769
+ continue
770
+
771
+ if _is_sibling_device(candidate, control_ports):
772
+ return candidate
773
+
774
+ return None
775
+
776
+
777
+ def _is_sibling_device(candidate: usb.core.Device, control_ports: List[int]) -> bool:
778
+ """Check if candidate device is a sibling of the control device."""
779
+ try:
780
+ candidate_ports = getattr(candidate, "port_numbers", [])
781
+ except (AttributeError, usb.core.USBError):
782
+ return False
783
+
784
+ # Ensure candidate_ports is not None and is a list/tuple
785
+ if candidate_ports is None:
786
+ return False
787
+
788
+ if not isinstance(candidate_ports, (list, tuple)):
789
+ return False
790
+
791
+ # Check if they share the same parent hub (same port path except last element)
792
+ if (
793
+ len(candidate_ports) >= 2
794
+ and len(control_ports) >= 2
795
+ and candidate_ports[:-1] == control_ports[:-1]
796
+ ):
797
+
798
+ # Check if it's a mass storage device
799
+ if isinstance(candidate, usb.core.Device) and _is_mass_storage_device(
800
+ candidate
801
+ ):
802
+ log.debug(f"Found sibling storage device for SDWireC: {candidate}")
803
+ return True
804
+
805
+ return False
806
+
807
+
808
+ def _is_mass_storage_device(device: usb.core.Device) -> bool:
809
+ """Check if a USB device is a mass storage device.
810
+
811
+ Args:
812
+ device: USB device to check
813
+
814
+ Returns:
815
+ True if the device is a mass storage device
816
+ """
817
+ try:
818
+ # Check device class
819
+ device_class = getattr(device, "bDeviceClass", None)
820
+ if device_class == USB_MASS_STORAGE_CLASS_ID:
821
+ return True
822
+
823
+ # Check interface class for composite devices
824
+ try:
825
+ config = device.get_active_configuration()
826
+ for interface in config:
827
+ if interface.bInterfaceClass == USB_MASS_STORAGE_CLASS_ID:
828
+ return True
829
+ except Exception as e:
830
+ log.debug(f"Could not access device interfaces: {e}")
831
+ # For composite devices where we can't access interfaces due to
832
+ # permissions, we'll use heuristics based on device class and
833
+ # known SDWire patterns
834
+ if device_class == 0: # Composite device
835
+ vendor_id = getattr(device, "idVendor", 0)
836
+ product_id = getattr(device, "idProduct", 0)
837
+
838
+ # Known storage device patterns for SDWire ecosystem
839
+ if vendor_id == SDWIRE3_VID and product_id == SDWIRE3_PID:
840
+ return True
841
+
842
+ # For SDWireC hub topology, if we find a composite device
843
+ # with permission issues that's a sibling of an FTDI device,
844
+ # it's likely the storage part.
845
+ if vendor_id == SDWIREC_VID and product_id == SDWIREC_PID:
846
+ return True
847
+
848
+ # Generic heuristic: if we can't determine the device type
849
+ # due to permissions and it's a composite device, assume it
850
+ # might be storage in SDWire context
851
+ return True
852
+
853
+ except Exception as e:
854
+ log.debug(f"Error checking if device is mass storage: {e}")
855
+
856
+ return False