Vendored 5 packages from iOpenPod (TheRealSavi/iOpenPod): iTunesDB_Writer/ — binary iTunesDB generation iTunesDB_Shared/ — field defs, constants, album identity SQLiteDB_Writer/ — SQLite databases for Nano 6G/7G iTunesDB_Parser/ — play stats reading from iPod ipod_device/ — device detection, FireWire ID, capabilities Rationale: iOpenPod is a standalone iPod manager app, not a library. Pulling it as a pip dependency is heavy and confusing for users. Changes: + src/vendor/ — 77 .py files + calcHashAB.wasm (506 KB) ~ ipod_nano7_db.py — _find_iop_root() now returns src/vendor/ ~ requirements.txt — iopenpod replaced with wasmtime + pycryptodome ~ .gitignore — removed iOpenPod/ exclusion (setup.py reads requirements.txt automatically)
618 lines
21 KiB
Python
618 lines
21 KiB
Python
"""
|
||
macOS-only IOKit SCSI VPD query for iPods.
|
||
|
||
Uses IOKit's SCSITaskLib CFPlugIn to send SCSI INQUIRY VPD commands
|
||
directly to iPod hardware without requiring root, driver detach, or
|
||
disk unmount. Provides the same dict shape as vpd_libusb so
|
||
ipod_device.info._enrich_from_usb_vpd can consume it unchanged.
|
||
|
||
Requirements: macOS only (IOKit framework). No third-party packages.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ctypes
|
||
import logging
|
||
import plistlib
|
||
import re
|
||
import struct
|
||
import sys
|
||
from ctypes import (
|
||
POINTER,
|
||
Structure,
|
||
byref,
|
||
c_char_p,
|
||
c_int32,
|
||
c_uint8,
|
||
c_uint32,
|
||
c_uint64,
|
||
c_void_p,
|
||
cast,
|
||
create_string_buffer,
|
||
)
|
||
|
||
if sys.platform != "darwin":
|
||
raise ImportError("ipod_device.vpd_iokit is macOS-only")
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
# ── IOKit / CoreFoundation via ctypes ────────────────────────────────
|
||
|
||
_cf = ctypes.cdll.LoadLibrary(
|
||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
|
||
)
|
||
_iok = ctypes.cdll.LoadLibrary(
|
||
"/System/Library/Frameworks/IOKit.framework/IOKit"
|
||
)
|
||
|
||
# CFString helpers
|
||
_cf.CFStringGetCString.argtypes = [c_void_p, c_char_p, c_int32, c_uint32]
|
||
_cf.CFStringGetCString.restype = ctypes.c_bool
|
||
_cf.CFRelease.argtypes = [c_void_p]
|
||
_cf.CFRelease.restype = None
|
||
_cf.CFGetTypeID.argtypes = [c_void_p]
|
||
_cf.CFGetTypeID.restype = c_uint64
|
||
_cf.CFStringGetTypeID.argtypes = []
|
||
_cf.CFStringGetTypeID.restype = c_uint64
|
||
_cf.CFNumberGetTypeID.argtypes = []
|
||
_cf.CFNumberGetTypeID.restype = c_uint64
|
||
_cf.CFNumberGetValue.argtypes = [c_void_p, c_int32, c_void_p]
|
||
_cf.CFNumberGetValue.restype = ctypes.c_bool
|
||
|
||
_cf.CFUUIDGetConstantUUIDWithBytes.restype = c_void_p
|
||
_cf.CFUUIDGetConstantUUIDWithBytes.argtypes = [c_void_p] + [c_uint8] * 16
|
||
|
||
_cf.CFStringCreateWithCString.argtypes = [c_void_p, c_char_p, c_uint32]
|
||
_cf.CFStringCreateWithCString.restype = c_void_p
|
||
kCFStringEncodingUTF8 = 0x08000100
|
||
|
||
# IOKit registry
|
||
_iok.IOServiceMatching.restype = c_void_p
|
||
_iok.IOServiceMatching.argtypes = [c_char_p]
|
||
_iok.IOServiceGetMatchingServices.argtypes = [c_uint32, c_void_p, POINTER(c_uint32)]
|
||
_iok.IOServiceGetMatchingServices.restype = c_int32
|
||
_iok.IOIteratorNext.argtypes = [c_uint32]
|
||
_iok.IOIteratorNext.restype = c_uint32
|
||
_iok.IOObjectRelease.argtypes = [c_uint32]
|
||
_iok.IOObjectRelease.restype = c_int32
|
||
_iok.IORegistryEntryGetParentEntry.argtypes = [c_uint32, c_char_p, POINTER(c_uint32)]
|
||
_iok.IORegistryEntryGetParentEntry.restype = c_int32
|
||
_iok.IORegistryEntryCreateCFProperty.argtypes = [c_uint32, c_void_p, c_void_p, c_uint32]
|
||
_iok.IORegistryEntryCreateCFProperty.restype = c_void_p
|
||
_iok.IORegistryEntryGetName.argtypes = [c_uint32, c_char_p]
|
||
_iok.IORegistryEntryGetName.restype = c_int32
|
||
|
||
_iok.IOCreatePlugInInterfaceForService.argtypes = [
|
||
c_uint32, c_void_p, c_void_p, POINTER(c_void_p), POINTER(c_int32)
|
||
]
|
||
_iok.IOCreatePlugInInterfaceForService.restype = c_int32
|
||
|
||
kIOServicePlane = b"IOService"
|
||
|
||
# ── UUIDs ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_uuid(*b: int) -> c_void_p:
|
||
return _cf.CFUUIDGetConstantUUIDWithBytes(None, *[c_uint8(x) for x in b])
|
||
|
||
|
||
_kSCSITaskDeviceUserClientTypeID = _make_uuid(
|
||
0x7D, 0x66, 0x67, 0x8E, 0x08, 0xA2, 0x11, 0xD5,
|
||
0xA1, 0xB8, 0x00, 0x30, 0x65, 0x7D, 0x05, 0x2A,
|
||
)
|
||
_kIOCFPlugInInterfaceID = _make_uuid(
|
||
0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4,
|
||
0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F,
|
||
)
|
||
# Actual SCSITaskDeviceInterfaceID (from binary disassembly — differs
|
||
# from the documented kSCSITaskDeviceInterfaceID = 6BD48AE0-…).
|
||
_kSCSITaskDeviceInterfaceID_bytes = bytes([
|
||
0x1B, 0xBC, 0x41, 0x32, 0x08, 0xA5, 0x11, 0xD5,
|
||
0x90, 0xED, 0x00, 0x30, 0x65, 0x7D, 0x05, 0x2A,
|
||
])
|
||
|
||
# ── SCSI structures ─────────────────────────────────────────────────
|
||
|
||
|
||
class _IOVirtualRange(Structure):
|
||
_fields_ = [("address", c_uint64), ("length", c_uint64)]
|
||
|
||
|
||
class _SCSISenseData(Structure):
|
||
_fields_ = [("data", c_uint8 * 18)]
|
||
|
||
|
||
_kSCSIDataTransfer_FromTargetToInitiator = 2
|
||
|
||
# ── COM vtable helpers ───────────────────────────────────────────────
|
||
#
|
||
# IOKit CFPlugIn interfaces use COM-style vtables:
|
||
# obj → *vtable → [NULL, QI, AddRef, Release, version(0x1), method5, ...]
|
||
#
|
||
# SCSITaskDeviceInterface vtable (slots 5–10):
|
||
# [5] IsExclusiveAccessAvailable [6] AddCallbackDispatcherToRunLoop
|
||
# [7] RemoveCallbackDispatcherFromRunLoop [8] ObtainExclusiveAccess
|
||
# [9] ReleaseExclusiveAccess [10] CreateSCSITask
|
||
#
|
||
# SCSITaskInterface vtable (slots 5–24):
|
||
# [5] IsTaskActive [6] SetTaskAttribute [7] GetTaskAttribute
|
||
# [8] SetCommandDescriptorBlock(self, uint8*, uint8)
|
||
# [9] GetCommandDescriptorBlockSize [10] GetCommandDescriptorBlock
|
||
# [11] SetScatterGatherEntries(self, IOVirtualRange*, uint8, uint64, uint8)
|
||
# [12] SetTimeoutDuration(self, uint32)
|
||
# [13] GetTimeoutDuration [14] SetTaskCompletionCallback
|
||
# [15] ExecuteTaskAsync
|
||
# [16] ExecuteTaskSync(self, SenseData*, TaskStatus*, uint64*)
|
||
# [17] AbortTask [18] GetServiceResponse [19] GetTaskState
|
||
# [20] GetTaskStatus [21] GetRealizedDataTransferCount
|
||
# [22] GetAutoSenseData [23] SetSenseDataBuffer
|
||
# [24] ResetForNewTask
|
||
|
||
|
||
def _vt_ptr(obj: c_void_p, slot: int):
|
||
"""Return raw function pointer at vtable[slot]."""
|
||
vt_base = cast(obj, POINTER(c_void_p))[0]
|
||
return cast(vt_base, POINTER(c_void_p))[slot]
|
||
|
||
|
||
def _vt_call(obj: c_void_p, slot: int, restype, argtypes, *args):
|
||
"""Call vtable[slot](obj, *args)."""
|
||
fptr = _vt_ptr(obj, slot)
|
||
fn = ctypes.CFUNCTYPE(restype, c_void_p, *argtypes)(fptr)
|
||
return fn(obj, *args)
|
||
|
||
|
||
# ── IOKit registry helpers ───────────────────────────────────────────
|
||
|
||
def _cf_property_string(entry: int, key: str) -> str | None:
|
||
"""Read a string property from an IOKit registry entry."""
|
||
cf_key = _cf.CFStringCreateWithCString(
|
||
None, key.encode(), kCFStringEncodingUTF8
|
||
)
|
||
if not cf_key:
|
||
return None
|
||
try:
|
||
cf_val = _iok.IORegistryEntryCreateCFProperty(entry, cf_key, None, 0)
|
||
if not cf_val:
|
||
return None
|
||
try:
|
||
if _cf.CFGetTypeID(cf_val) != _cf.CFStringGetTypeID():
|
||
return None
|
||
buf = ctypes.create_string_buffer(512)
|
||
if _cf.CFStringGetCString(cf_val, buf, 512, kCFStringEncodingUTF8):
|
||
return buf.value.decode("utf-8", errors="replace")
|
||
return None
|
||
finally:
|
||
_cf.CFRelease(cf_val)
|
||
finally:
|
||
_cf.CFRelease(cf_key)
|
||
|
||
|
||
def _cf_property_int(entry: int, key: str) -> int | None:
|
||
"""Read an integer property from an IOKit registry entry."""
|
||
cf_key = _cf.CFStringCreateWithCString(
|
||
None, key.encode(), kCFStringEncodingUTF8
|
||
)
|
||
if not cf_key:
|
||
return None
|
||
try:
|
||
cf_val = _iok.IORegistryEntryCreateCFProperty(entry, cf_key, None, 0)
|
||
if not cf_val:
|
||
return None
|
||
try:
|
||
if _cf.CFGetTypeID(cf_val) != _cf.CFNumberGetTypeID():
|
||
return None
|
||
val = c_int32(0)
|
||
# kCFNumberSInt32Type = 3
|
||
_cf.CFNumberGetValue(cf_val, 3, byref(val))
|
||
return val.value
|
||
finally:
|
||
_cf.CFRelease(cf_val)
|
||
finally:
|
||
_cf.CFRelease(cf_key)
|
||
|
||
|
||
def _walk_parents_for_usb_info(service: int) -> dict:
|
||
"""Walk IOKit registry parents to find USB device properties."""
|
||
result: dict = {}
|
||
entry = service
|
||
# Walk up to 10 levels — wrapped in try/finally so the last
|
||
# parent IOKit handle is always released even on unexpected errors.
|
||
try:
|
||
for _ in range(10):
|
||
parent = c_uint32(0)
|
||
kr = _iok.IORegistryEntryGetParentEntry(entry, kIOServicePlane, byref(parent))
|
||
if kr != 0:
|
||
break
|
||
# Check for USB device properties
|
||
pid = _cf_property_int(parent.value, "idProduct")
|
||
if pid is not None and "usb_pid" not in result:
|
||
result["usb_pid"] = pid
|
||
vid = _cf_property_int(parent.value, "idVendor")
|
||
if vid is not None and "usb_vid" not in result:
|
||
result["usb_vid"] = vid
|
||
serial = _cf_property_string(parent.value, "USB Serial Number")
|
||
if serial and "usb_serial" not in result:
|
||
result["usb_serial"] = serial
|
||
if entry != service:
|
||
_iok.IOObjectRelease(entry)
|
||
entry = parent.value
|
||
if "usb_pid" in result and "usb_serial" in result:
|
||
break
|
||
finally:
|
||
if entry != service:
|
||
try:
|
||
_iok.IOObjectRelease(entry)
|
||
except Exception:
|
||
pass
|
||
return result
|
||
|
||
|
||
# ── SCSI command helpers ─────────────────────────────────────────────
|
||
|
||
class _SCSISession:
|
||
"""Manages exclusive access to one iPod's SCSI interface."""
|
||
|
||
def __init__(self, service: int):
|
||
self._service = service
|
||
self._plugin: c_void_p | None = None
|
||
self._device_if: c_void_p | None = None
|
||
self._task = None
|
||
self._exclusive = False
|
||
|
||
def open(self) -> bool:
|
||
"""Create plugin → QueryInterface → ObtainExclusiveAccess → CreateTask."""
|
||
# 1. Create plugin
|
||
plugin = c_void_p(0)
|
||
score = c_int32(0)
|
||
kr = _iok.IOCreatePlugInInterfaceForService(
|
||
self._service,
|
||
_kSCSITaskDeviceUserClientTypeID,
|
||
_kIOCFPlugInInterfaceID,
|
||
byref(plugin),
|
||
byref(score),
|
||
)
|
||
if kr != 0:
|
||
log.debug("IOCreatePlugInInterfaceForService failed: 0x%08X", kr)
|
||
return False
|
||
self._plugin = plugin
|
||
|
||
# 2. QueryInterface for SCSITaskDeviceInterface
|
||
uuid_lo, uuid_hi = struct.unpack("<QQ", _kSCSITaskDeviceInterfaceID_bytes)
|
||
device_if = c_void_p(0)
|
||
hr = _vt_call(
|
||
plugin, 1, c_uint32,
|
||
[c_uint64, c_uint64, POINTER(c_void_p)],
|
||
c_uint64(uuid_lo), c_uint64(uuid_hi), byref(device_if),
|
||
)
|
||
if hr != 0:
|
||
log.debug("QueryInterface failed: 0x%08X", hr)
|
||
return False
|
||
self._device_if = device_if
|
||
|
||
# 3. ObtainExclusiveAccess [8]
|
||
kr = _vt_call(device_if, 8, c_int32, [])
|
||
if kr != 0:
|
||
log.debug("ObtainExclusiveAccess failed: 0x%08X", kr)
|
||
return False
|
||
self._exclusive = True
|
||
|
||
# 4. CreateSCSITask [10]
|
||
task = _vt_call(device_if, 10, c_void_p, [])
|
||
if not task:
|
||
log.debug("CreateSCSITask returned NULL")
|
||
return False
|
||
self._task = task
|
||
return True
|
||
|
||
def close(self):
|
||
"""Release all resources. Each step is guarded so a failure
|
||
in one does not prevent cleanup of the others."""
|
||
if self._task:
|
||
try:
|
||
_vt_call(c_void_p(self._task), 3, c_uint32, []) # Release
|
||
except Exception:
|
||
log.debug("SCSISession: task Release failed", exc_info=True)
|
||
self._task = None
|
||
if self._exclusive and self._device_if:
|
||
try:
|
||
_vt_call(self._device_if, 9, c_int32, []) # ReleaseExclusive
|
||
except Exception:
|
||
log.debug("SCSISession: ReleaseExclusive failed", exc_info=True)
|
||
self._exclusive = False
|
||
if self._device_if:
|
||
try:
|
||
_vt_call(self._device_if, 3, c_uint32, []) # Release
|
||
except Exception:
|
||
log.debug("SCSISession: device_if Release failed", exc_info=True)
|
||
self._device_if = None
|
||
if self._plugin:
|
||
try:
|
||
_vt_call(self._plugin, 3, c_uint32, []) # Release
|
||
except Exception:
|
||
log.debug("SCSISession: plugin Release failed", exc_info=True)
|
||
self._plugin = None
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *_):
|
||
self.close()
|
||
|
||
def inquiry(self, evpd: bool, page: int, alloc_len: int = 255) -> bytes | None:
|
||
"""Send SCSI INQUIRY. Returns response bytes or None on failure."""
|
||
if not self._task:
|
||
return None
|
||
|
||
task = c_void_p(self._task)
|
||
|
||
# ResetForNewTask [24]
|
||
_vt_call(task, 24, c_int32, [])
|
||
|
||
# SetCDB [8]
|
||
cdb = (c_uint8 * 16)(
|
||
0x12,
|
||
0x01 if evpd else 0x00,
|
||
page & 0xFF,
|
||
0x00,
|
||
alloc_len & 0xFF,
|
||
0x00,
|
||
*([0] * 10),
|
||
)
|
||
kr = _vt_call(
|
||
task, 8, c_int32,
|
||
[POINTER(c_uint8), c_uint8],
|
||
cast(cdb, POINTER(c_uint8)), c_uint8(6),
|
||
)
|
||
if kr != 0:
|
||
return None
|
||
|
||
# SetScatterGatherEntries [11]
|
||
resp = create_string_buffer(alloc_len)
|
||
iovr = _IOVirtualRange(address=ctypes.addressof(resp), length=alloc_len)
|
||
kr = _vt_call(
|
||
task, 11, c_int32,
|
||
[POINTER(_IOVirtualRange), c_uint8, c_uint64, c_uint8],
|
||
byref(iovr), c_uint8(1), c_uint64(alloc_len),
|
||
c_uint8(_kSCSIDataTransfer_FromTargetToInitiator),
|
||
)
|
||
if kr != 0:
|
||
return None
|
||
|
||
# SetTimeoutDuration [12]
|
||
_vt_call(task, 12, c_int32, [c_uint32], c_uint32(10000))
|
||
|
||
# ExecuteTaskSync [16]
|
||
sense = _SCSISenseData()
|
||
status = c_uint32(0)
|
||
realized = c_uint64(0)
|
||
kr = _vt_call(
|
||
task, 16, c_int32,
|
||
[POINTER(_SCSISenseData), POINTER(c_uint32), POINTER(c_uint64)],
|
||
byref(sense), byref(status), byref(realized),
|
||
)
|
||
if kr != 0 or realized.value == 0:
|
||
return None
|
||
|
||
return bytes(resp)[: realized.value]
|
||
|
||
|
||
# ── VPD parsing ──────────────────────────────────────────────────────
|
||
|
||
def _read_vpd_pages(session: _SCSISession) -> bytes:
|
||
"""Read and concatenate VPD data pages (0xC2+) from iPod."""
|
||
# Page 0xC0 lists the available data pages
|
||
data_pages: list[int] = []
|
||
c0 = session.inquiry(evpd=True, page=0xC0)
|
||
if c0 and len(c0) >= 4:
|
||
count = c0[3]
|
||
data_pages = [p for p in c0[4:4 + count] if p >= 0xC2]
|
||
if not data_pages:
|
||
# Fallback: try 0xC2–0xFF
|
||
data_pages = list(range(0xC2, 0x100))
|
||
|
||
raw = bytearray()
|
||
for page in data_pages:
|
||
resp = session.inquiry(evpd=True, page=page)
|
||
if not resp or len(resp) < 4:
|
||
continue
|
||
payload_len = resp[3]
|
||
payload = resp[4:4 + payload_len]
|
||
if not any(payload):
|
||
continue
|
||
raw.extend(payload)
|
||
|
||
# Strip trailing nulls
|
||
return bytes(raw).rstrip(b"\x00")
|
||
|
||
|
||
def _parse_vpd_xml(raw: bytes) -> dict:
|
||
"""Parse XML plist from concatenated VPD page data."""
|
||
result: dict = {}
|
||
if not raw:
|
||
return result
|
||
|
||
# Find XML start
|
||
for marker in (b"<?xml", b"<plist"):
|
||
idx = raw.find(marker)
|
||
if idx >= 0:
|
||
raw = raw[idx:]
|
||
break
|
||
|
||
# Ensure plist is closed (may be truncated)
|
||
if b"</plist>" not in raw:
|
||
raw = raw + b"\n</dict>\n</plist>"
|
||
|
||
try:
|
||
plist = plistlib.loads(raw)
|
||
if isinstance(plist, dict):
|
||
result = plist
|
||
except Exception:
|
||
# Fallback: regex extraction
|
||
result = _parse_vpd_regex(raw)
|
||
|
||
return result
|
||
|
||
|
||
def _parse_vpd_regex(raw: bytes) -> dict:
|
||
"""Regex fallback for truncated XML plists."""
|
||
result: dict = {}
|
||
text = raw.decode("utf-8", errors="replace")
|
||
for m in re.finditer(
|
||
r"<key>([^<]+)</key>\s*<(string|integer)>([^<]*)</\2>", text
|
||
):
|
||
key, typ, val = m.group(1), m.group(2), m.group(3)
|
||
if typ == "integer":
|
||
try:
|
||
result[key] = int(val)
|
||
except ValueError:
|
||
result[key] = val
|
||
else:
|
||
result[key] = val
|
||
return result
|
||
|
||
|
||
# ── Public API ───────────────────────────────────────────────────────
|
||
|
||
def query_ipod_vpd(
|
||
usb_pid: int = 0, serial_filter: str = ""
|
||
) -> dict | None:
|
||
"""
|
||
Query a single iPod's device info via IOKit SCSI VPD.
|
||
|
||
No root required. Does not detach the mass-storage driver or
|
||
unmount the iPod disk — the device stays mounted throughout.
|
||
|
||
Parameters
|
||
----------
|
||
usb_pid : int
|
||
Target specific USB Product ID (0 = any).
|
||
serial_filter : str
|
||
Target specific USB serial / FireWire GUID (case-insensitive).
|
||
|
||
Returns
|
||
-------
|
||
dict or None
|
||
Keys include ``SerialNumber``, ``FireWireGUID``, ``FamilyID``,
|
||
``UpdaterFamilyID``, ``vpd_raw_xml``, ``scsi_vendor``, etc.
|
||
"""
|
||
match_dict = _iok.IOServiceMatching(b"com_apple_driver_iPodSBCNub")
|
||
if not match_dict:
|
||
return None
|
||
iterator = c_uint32(0)
|
||
kr = _iok.IOServiceGetMatchingServices(0, match_dict, byref(iterator))
|
||
if kr != 0:
|
||
return None
|
||
|
||
try:
|
||
while True:
|
||
svc = _iok.IOIteratorNext(iterator.value)
|
||
if svc == 0:
|
||
break
|
||
try:
|
||
result = _query_one_service(svc, usb_pid, serial_filter)
|
||
if result is not None:
|
||
return result
|
||
except Exception as exc:
|
||
log.debug("Failed to query iPod service %d: %s", svc, exc)
|
||
finally:
|
||
_iok.IOObjectRelease(svc)
|
||
finally:
|
||
_iok.IOObjectRelease(iterator.value)
|
||
|
||
return None
|
||
|
||
|
||
def query_all_ipods() -> list[dict]:
|
||
"""
|
||
Query every connected iPod via IOKit SCSI VPD.
|
||
|
||
Returns a list of dicts (same format as ``query_ipod_vpd``).
|
||
No root required; disks stay mounted.
|
||
"""
|
||
match_dict = _iok.IOServiceMatching(b"com_apple_driver_iPodSBCNub")
|
||
if not match_dict:
|
||
return []
|
||
iterator = c_uint32(0)
|
||
kr = _iok.IOServiceGetMatchingServices(0, match_dict, byref(iterator))
|
||
if kr != 0:
|
||
return []
|
||
|
||
results: list[dict] = []
|
||
try:
|
||
while True:
|
||
svc = _iok.IOIteratorNext(iterator.value)
|
||
if svc == 0:
|
||
break
|
||
try:
|
||
result = _query_one_service(svc, 0, "")
|
||
if result is not None:
|
||
results.append(result)
|
||
except Exception as exc:
|
||
log.debug("Failed to query iPod service %d: %s", svc, exc)
|
||
finally:
|
||
_iok.IOObjectRelease(svc)
|
||
finally:
|
||
_iok.IOObjectRelease(iterator.value)
|
||
|
||
return results
|
||
|
||
|
||
# ── Internal ─────────────────────────────────────────────────────────
|
||
|
||
def _query_one_service(
|
||
service: int, usb_pid: int, serial_filter: str
|
||
) -> dict | None:
|
||
"""Query one iPod IOKit service. Returns dict or None."""
|
||
# Get USB info from IOKit registry parents
|
||
usb_info = _walk_parents_for_usb_info(service)
|
||
|
||
# Apply filters
|
||
if usb_pid and usb_info.get("usb_pid") != usb_pid:
|
||
return None
|
||
if serial_filter:
|
||
svc_serial = usb_info.get("usb_serial", "")
|
||
if svc_serial.upper() != serial_filter.upper():
|
||
return None
|
||
|
||
with _SCSISession(service) as session:
|
||
if not session.open():
|
||
log.debug("Failed to open SCSI session for service %d", service)
|
||
return None
|
||
|
||
info: dict = {
|
||
"_source": "scsi_vpd",
|
||
"_transport": "iokit_scsi_vpd",
|
||
}
|
||
|
||
# USB identifiers
|
||
if "usb_vid" in usb_info:
|
||
info["usb_vid"] = usb_info["usb_vid"]
|
||
if "usb_pid" in usb_info:
|
||
info["usb_pid"] = usb_info["usb_pid"]
|
||
if "usb_serial" in usb_info:
|
||
info["usb_serial"] = usb_info["usb_serial"]
|
||
|
||
# Standard INQUIRY
|
||
std = session.inquiry(evpd=False, page=0, alloc_len=96)
|
||
if std and len(std) >= 36:
|
||
info["scsi_vendor"] = std[8:16].decode("ascii", errors="replace").strip()
|
||
info["scsi_product"] = std[16:32].decode("ascii", errors="replace").strip()
|
||
info["scsi_revision"] = std[32:36].decode("ascii", errors="replace").strip()
|
||
|
||
# VPD page 0x80 — Unit Serial Number
|
||
p80 = session.inquiry(evpd=True, page=0x80)
|
||
if p80 and len(p80) > 4:
|
||
sn = p80[4:].split(b"\x00", 1)[0].decode("ascii", errors="replace").strip()
|
||
if sn:
|
||
info["vpd_serial"] = sn
|
||
|
||
# VPD data pages → XML plist
|
||
raw_xml = _read_vpd_pages(session)
|
||
if raw_xml:
|
||
info["vpd_raw_xml"] = raw_xml
|
||
plist_data = _parse_vpd_xml(raw_xml)
|
||
info.update(plist_data)
|
||
|
||
return info if info else None
|