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.
- sdwire/__init__.py +5 -0
- sdwire/backend/block_device_utils.py +856 -0
- sdwire/backend/detect.py +81 -49
- sdwire/backend/device/sdwire.py +49 -15
- sdwire/backend/device/sdwirec.py +56 -22
- sdwire/backend/device/usb_device.py +22 -10
- sdwire/backend/utils.py +48 -16
- sdwire/constants.py +2 -0
- sdwire/main.py +15 -5
- {sdwire-0.2.5.dist-info → sdwire-0.3.0.dist-info}/METADATA +1 -2
- sdwire-0.3.0.dist-info/RECORD +15 -0
- sdwire-0.2.5.dist-info/RECORD +0 -14
- {sdwire-0.2.5.dist-info → sdwire-0.3.0.dist-info}/LICENSE +0 -0
- {sdwire-0.2.5.dist-info → sdwire-0.3.0.dist-info}/WHEEL +0 -0
- {sdwire-0.2.5.dist-info → sdwire-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -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
|