Maksim Totmin 71f35c53cb refactor: vendor iOpenPod core modules instead of pip dependency
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)
2026-06-02 09:16:19 +07:00

2525 lines
96 KiB
Python

"""
iPod device scanner — discovers connected iPods by scanning mounted drives.
Uses a unified "gather everything, synthesize once" pipeline that combines
ALL available data sources and picks the best value for each field.
Detection pipeline:
**Phase 1 — Hardware probing** (pure Win32, no file I/O, no subprocess):
1a. IOCTL_STORAGE_QUERY_PROPERTY → vendor, product, firmware, Apple serial
1b. PnP device tree walk (SetupAPI/cfgmgr32) → FireWire GUID, USB PID
1c. If both fail: silent fallback to WMI (PowerShell + registry)
**Phase 2 — Filesystem probing** (file reads on iPod):
2a. SysInfo / SysInfoExtended → ModelNumStr, FireWire GUID, serial
2b. iTunesDB header → hashing_scheme (generation class)
**Phase 3 — Model resolution** (pure computation, per-field priority):
- model_number: SysInfo ModelNumStr → IPOD_MODELS > serial last-3 → IPOD_MODELS
- firewire_guid: device tree > SysInfoExtended > SysInfo > USB serial (always 16 hex chars on iPods)
- serial: SysInfo pszSerialNumber (Apple serial) > IOCTL (only if non-GUID)
- firmware: IOCTL revision > SysInfo visibleBuildID
- usb_pid: device tree USB parent > WMI fallback
- model_family: IPOD_MODELS > USB PID table (with disk-size sanity check) > hashing_scheme
**Phase 4 — Inline VPD** (macOS only, for incomplete identification):
If model_number is still unknown after Phase 3, query the iPod's
firmware via IOKit SCSI VPD (~1 s, no root, disk stays mounted)
to get the Apple serial. Serial-last-3 lookup resolves exact model
(family, generation, capacity, color). Writes SysInfo to the iPod
so that subsequent scans never need VPD again.
"""
import ctypes
import logging
import os
import struct
import subprocess
import sys
import threading
from pathlib import Path
from typing import TYPE_CHECKING
if sys.platform == "win32":
import ctypes.wintypes as wt
elif TYPE_CHECKING:
import ctypes.wintypes as wt # type-checker only
from .diagnostic_log import (
CAPABILITY_FIELDS,
IDENTITY_FIELDS,
SOURCE_FIELDS,
format_conflicts,
format_fields,
format_sources,
)
from .info import DeviceInfo
from .models import USB_PID_TO_MODEL
logger = logging.getLogger(__name__)
_PROBE_META_FIELDS: tuple[tuple[str, str], ...] = (
("_sysinfo_present", "sysinfo"),
("_sysinfo_keys", "sysinfo_keys"),
("_sysinfo_extended_present", "sie"),
("_sysinfo_extended_keys", "sie_keys"),
("_sysinfo_extended_regex_fallback", "sie_regex"),
("hashing_scheme", "hash_scheme"),
)
# Prevents console windows from flashing on Windows during subprocess calls
_SP_KWARGS: dict = (
{"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}
)
def _extract_guid_from_instance_id(instance_id: str) -> str:
"""
Extract the FireWire GUID (16-char hex string) from a USBSTOR or USB
instance ID.
The instance ID format depends on whether the USB device reports
``UniqueID=TRUE`` or ``FALSE``:
- **UniqueID=TRUE** (simple USB, e.g. Nano 2G):
``000A270018A1F847&0``
→ GUID is the first ``&``-separated segment.
- **UniqueID=FALSE** (composite USB, e.g. Classic):
``8&2F161EF4&0&000A2700138A422D&0``
→ PnP prepends a scope-hash prefix. The GUID is still present
as a 16-char hex segment, just not the first one.
This helper scans ALL ``&``-separated segments and returns the first
that is exactly 16 hex characters. Returns empty string if not found.
"""
for segment in instance_id.split("&"):
segment = segment.strip()
if len(segment) == 16:
try:
bytes.fromhex(segment)
return segment.upper()
except ValueError:
pass
return ""
def _get_drive_letters() -> list[str]:
"""Get all available drive letters on Windows."""
if sys.platform != "win32":
return []
import ctypes
bitmask = ctypes.windll.kernel32.GetLogicalDrives() # type: ignore[attr-defined]
letters = []
for i in range(26):
if bitmask & (1 << i):
letter = chr(65 + i)
letters.append(letter)
return letters
def _has_ipod_control(drive_path: str) -> bool:
"""Check if a drive has iPod_Control at its root."""
ipod_control = os.path.join(drive_path, "iPod_Control")
return os.path.isdir(ipod_control)
def _get_disk_info(drive_path: str) -> tuple[float, float]:
"""Get disk size and free space in GB."""
try:
import shutil
usage = shutil.disk_usage(drive_path)
return usage.total / (1024**3), usage.free / (1024**3)
except OSError:
return 0.0, 0.0
def _find_ipod_volumes() -> list[tuple[str, str]]:
"""
Find mounted volumes that contain an iPod_Control directory.
Returns a list of (mount_path, display_name) tuples.
Cross-platform: Windows drive letters, macOS /Volumes, Linux common mount dirs.
"""
candidates: list[tuple[str, str]] = []
if sys.platform == "win32":
for letter in _get_drive_letters():
drive_path = f"{letter}:\\"
try:
if _has_ipod_control(drive_path):
candidates.append((drive_path, f"{letter}:"))
except PermissionError:
continue
elif sys.platform == "darwin":
# macOS: iPods mount under /Volumes/
volumes_dir = "/Volumes"
if os.path.isdir(volumes_dir):
for name in os.listdir(volumes_dir):
vol_path = os.path.join(volumes_dir, name)
if os.path.isdir(vol_path):
try:
if _has_ipod_control(vol_path):
candidates.append((vol_path, name))
except PermissionError:
continue
else:
# Linux: check common mount locations
import getpass
user = getpass.getuser()
search_dirs = [
f"/media/{user}",
f"/run/media/{user}",
"/mnt",
]
# Also check /media/* for distros that mount directly under /media
if os.path.isdir("/media"):
try:
for entry in os.listdir("/media"):
d = os.path.join("/media", entry)
if os.path.isdir(d) and d not in search_dirs:
search_dirs.append(d)
except PermissionError:
pass
seen: set[str] = set()
for search_dir in search_dirs:
if not os.path.isdir(search_dir):
continue
try:
entries = os.listdir(search_dir)
except PermissionError:
continue
for name in entries:
vol_path = os.path.join(search_dir, name)
if vol_path in seen or not os.path.isdir(vol_path):
continue
seen.add(vol_path)
try:
if _has_ipod_control(vol_path):
candidates.append((vol_path, name))
except PermissionError:
continue
logger.debug(
"iPod volume discovery: platform=%s count=%d mounts=%s",
sys.platform,
len(candidates),
", ".join(display for _path, display in candidates) or "none",
)
return candidates
# ── macOS: BSD name → USB serial mapping via ioreg text parsing ────────
#
# ioreg's plist (-a) format does NOT include "BSD Name" on child IOMedia
# nodes, but the text format does. We parse the text output to build a
# mapping from BSD whole-disk name (e.g. "disk4") to the owning USB
# device's serial number. A second (plist) query then gives us the
# full device properties keyed by serial.
#
# Cache is built once per scan cycle and cleared at the end of
# scan_for_ipods().
_macos_bsd_to_serial: dict[str, str] | None = None
_macos_serial_to_dev: dict[str, dict] | None = None
_macos_cache_lock = threading.Lock()
def _clear_macos_usb_cache() -> None:
"""Clear cached macOS USB maps between scan/identify cycles."""
global _macos_bsd_to_serial, _macos_serial_to_dev
with _macos_cache_lock:
_macos_bsd_to_serial = None
_macos_serial_to_dev = None
def _build_macos_usb_cache() -> None:
"""Build both caches from ioreg in one shot.
Caller must hold ``_macos_cache_lock``.
"""
global _macos_bsd_to_serial, _macos_serial_to_dev
import plistlib
import re as _re
import subprocess
bsd_map: dict[str, str] = {}
dev_map: dict[str, dict] = {}
# ── 1. Text parse: map BSD whole-disk → USB serial ─────────────────
#
# The text ioreg output for iPod nodes looks like:
# +-o iPod@01130000 <class IOUSBHostDevice, ...>
# | "USB Serial Number" = "000A270018A1F847"
# | "idProduct" = 4704
# ...
# +-o Apple iPod Media <class IOMedia, ...>
# | "BSD Name" = "disk4"
#
# We track the current USB serial as we scan lines. When we hit a
# "BSD Name" at a deeper indent, we know which USB device owns it.
try:
proc = subprocess.run(
["ioreg", "-r", "-c", "IOUSBHostDevice", "-n", "iPod",
"-l", "-d", "20", "-w", "0"],
capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=8,
)
if proc.returncode == 0 and proc.stdout:
current_serial: str = ""
for line in proc.stdout.splitlines():
# USB Serial Number
m = _re.search(r'"USB Serial Number"\s*=\s*"([^"]+)"', line)
if m:
current_serial = m.group(1).replace(" ", "").strip()
continue
# BSD Name on IOMedia child
m = _re.search(r'"BSD Name"\s*=\s*"(disk\d+)"', line)
if m and current_serial:
bsd_map[m.group(1)] = current_serial.upper()
except subprocess.TimeoutExpired:
logger.warning("macOS: ioreg text parse timed out")
except Exception as e:
logger.debug("macOS: ioreg text parse failed: %s", e)
if bsd_map:
logger.debug("macOS: BSD→serial map: %s", bsd_map)
# ── 2. Plist query: full device properties keyed by serial ─────────
for ioreg_args in [
["ioreg", "-a", "-r", "-c", "IOUSBHostDevice", "-n", "iPod"],
["ioreg", "-a", "-r", "-d", "1", "-c", "IOUSBHostDevice"],
]:
if dev_map:
break
try:
proc = subprocess.run(
ioreg_args, capture_output=True, timeout=10,
)
if proc.returncode != 0 or not proc.stdout.strip():
continue
parsed = plistlib.loads(proc.stdout)
if not isinstance(parsed, list):
parsed = [parsed]
for dev in parsed:
if dev.get("idVendor", 0) != 0x05AC:
continue
serial = (dev.get("USB Serial Number", "") or dev.get("kUSBSerialNumberString", ""))
key = serial.replace(" ", "").strip().upper()
if key:
dev_map[key] = dev
except Exception as e:
logger.debug("ioreg plist query failed: %s", e)
_macos_bsd_to_serial = bsd_map
_macos_serial_to_dev = dev_map
def _probe_hardware_macos(mount_path: str) -> dict:
"""
macOS hardware probing via ioreg + diskutil.
**Matching strategy**: ``diskutil info`` gives the BSD whole-disk name
for each volume. A text-format ``ioreg`` query maps BSD names to USB
serial numbers (because the text format exposes "BSD Name" on IOMedia
children, while the plist format does not). A second plist query gives
full device properties (PID, serial, firmware) keyed by serial.
This correctly associates each volume with its own USB device even when
multiple iPods are connected, without relying on SysInfo files.
"""
import plistlib
import subprocess
result: dict = {}
# ── Step 1: Confirm USB bus and get BSD whole-disk via diskutil ─────
bsd_whole_disk: str | None = None
try:
proc = subprocess.run(
["diskutil", "info", "-plist", mount_path],
capture_output=True, timeout=10,
)
if proc.returncode == 0:
disk_info = plistlib.loads(proc.stdout)
if disk_info.get("BusProtocol") != "USB":
logger.debug(
"macOS probe: %s is not on USB (protocol=%s)",
mount_path, disk_info.get("BusProtocol"),
)
return result
bsd_whole_disk = disk_info.get("ParentWholeDisk")
except Exception as e:
logger.debug("diskutil info failed for %s: %s", mount_path, e)
# ── Step 2: Ensure the ioreg caches are built ──────────────────────
with _macos_cache_lock:
if _macos_bsd_to_serial is None or _macos_serial_to_dev is None:
_build_macos_usb_cache()
bsd_map = _macos_bsd_to_serial or {}
dev_map = _macos_serial_to_dev or {}
if not dev_map:
logger.debug("macOS probe: no Apple USB devices found in ioreg")
return result
# ── Step 3: Match this volume's BSD name → USB serial → device ─────
target_dev: dict | None = None
if bsd_whole_disk and bsd_whole_disk in bsd_map:
serial_key = bsd_map[bsd_whole_disk]
target_dev = dev_map.get(serial_key)
if target_dev:
logger.debug(
"macOS probe: %s%s → FW GUID %s → PID 0x%04X",
mount_path, bsd_whole_disk, serial_key,
target_dev.get("idProduct", 0),
)
# Fallback: if only one USB device, use it directly
if not target_dev and len(dev_map) == 1:
target_dev = next(iter(dev_map.values()))
if not target_dev:
logger.debug(
"macOS probe: could not match %s (bsd=%s) to any Apple "
"USB device", mount_path, bsd_whole_disk or "unknown",
)
return result
# ── Step 4: Extract device info ────────────────────────────────────
pid = target_dev.get("idProduct", 0)
if pid:
result["usb_pid"] = pid
model_info = USB_PID_TO_MODEL.get(pid)
if model_info:
result["model_family"] = model_info[0]
result["generation"] = model_info[1]
# The USB serial number for iPods is the FireWire GUID (16 hex
# chars), NOT the Apple serial number. Store it only as
# firewire_guid — the real Apple serial comes from SysInfo.
usb_serial = (target_dev.get("USB Serial Number", "") or target_dev.get("kUSBSerialNumberString", ""))
if usb_serial:
clean = usb_serial.replace(" ", "").strip()
if len(clean) == 16:
try:
bytes.fromhex(clean)
result["firewire_guid"] = clean.upper()
logger.debug("macOS probe: USB serial is FW GUID: %s", clean.upper())
except ValueError:
pass
bcd = target_dev.get("bcdDevice", 0)
if bcd:
major = (bcd >> 8) & 0xFF
minor = bcd & 0xFF
result["firmware"] = f"{major}.{minor:02d}"
return result
def _linux_find_block_device(mount_path: str) -> str | None:
"""Resolve a mount path to its block device (e.g. ``/dev/sdb1``).
Tries three strategies in order:
1. ``findmnt`` — present on all modern distros (util-linux), handles
paths with spaces, special characters, and bind mounts correctly.
2. ``/proc/mounts`` with octal-escape decoding — the kernel escapes
spaces as ``\\040``, tabs as ``\\011``, etc. Previous code compared
the raw escaped field to the unescaped *mount_path*, which failed for
mount points containing spaces
3. ``lsblk --json`` — robust JSON output, handles spaces in mount points.
"""
import re as _re
# ── Strategy 1: findmnt (best, handles all edge cases) ─────────
try:
cp = subprocess.run(
["findmnt", "-n", "-o", "SOURCE", "--target", mount_path],
capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=5,
)
if cp.returncode == 0:
dev = cp.stdout.strip().split("\n")[0].strip()
if dev.startswith("/dev/"):
logger.debug("Linux block device via findmnt: %s", dev)
return dev
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# ── Strategy 2: /proc/mounts with octal-escape decode ──────────
def _decode_mount_octal(field: str) -> str:
"""Decode octal escapes (\\040 → space, \\011 → tab, etc.)."""
return _re.sub(
r"\\([0-7]{3})",
lambda m: chr(int(m.group(1), 8)),
field,
)
try:
with open("/proc/mounts") as f:
for line in f:
parts = line.split()
if len(parts) >= 2:
decoded_mount = _decode_mount_octal(parts[1])
if decoded_mount == mount_path:
dev = parts[0]
if dev.startswith("/dev/"):
logger.debug("Linux block device via /proc/mounts: %s", dev)
return dev
except OSError:
pass
# ── Strategy 3: lsblk JSON ─────────────────────────────────────
try:
import json as _json
cp = subprocess.run(
["lsblk", "-J", "-o", "NAME,MOUNTPOINT"],
capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=5,
)
if cp.returncode == 0:
data = _json.loads(cp.stdout)
for dev_entry in data.get("blockdevices", []):
for child in dev_entry.get("children", []):
mp = child.get("mountpoint") or ""
if mp == mount_path:
name = child.get("name", "")
if name:
logger.debug("Linux block device via lsblk: /dev/%s", name)
return f"/dev/{name}"
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
pass
return None
def _linux_usb_info_from_udevadm(device: str) -> dict:
"""Extract USB identity fields via ``udevadm info``.
Returns a dict that may contain *usb_pid*, *firewire_guid*,
*model_family*, and *generation*. Non-destructive — does not detach
drivers or require root.
"""
result: dict = {}
try:
cp = subprocess.run(
["udevadm", "info", "--query=property", "--name", device],
capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=5,
)
if cp.returncode != 0:
return result
props: dict[str, str] = {}
for line in cp.stdout.splitlines():
if "=" in line:
k, _, v = line.partition("=")
props[k.strip()] = v.strip()
# Vendor check — only proceed for Apple devices
id_vendor = props.get("ID_VENDOR_ID", "")
if id_vendor != "05ac":
logger.debug("Linux udevadm %s: not Apple (vendor=%s)", device, id_vendor or "missing")
return result
# USB PID
id_model = props.get("ID_MODEL_ID", "")
if id_model:
try:
pid = int(id_model, 16)
result["usb_pid"] = pid
model_info = USB_PID_TO_MODEL.get(pid)
if model_info:
result["model_family"] = model_info[0]
result["generation"] = model_info[1]
except ValueError:
pass
# USB serial — on iPods this is the 16-hex-char FireWire GUID
usb_serial = props.get("ID_SERIAL_SHORT", "")
if usb_serial:
clean = usb_serial.replace(" ", "")
if len(clean) == 16:
try:
bytes.fromhex(clean)
result["firewire_guid"] = clean.upper()
except ValueError:
pass
if result:
logger.debug("Linux udevadm info: %s", result)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return result
def _linux_usb_info_from_sysfs(base_disk: str) -> dict:
"""Walk sysfs from a block device up to the USB ancestor.
Returns a dict that may contain *usb_pid*, *firewire_guid*,
*model_family*, and *generation*.
"""
result: dict = {}
sysfs_path = f"/sys/block/{base_disk}/device"
if not os.path.exists(sysfs_path):
return result
current = os.path.realpath(sysfs_path)
for _ in range(8):
vendor_file = os.path.join(current, "idVendor")
if os.path.exists(vendor_file):
with open(vendor_file) as vf:
vendor = vf.read().strip()
if vendor == "05ac": # Apple
# Read product ID
product_file = os.path.join(current, "idProduct")
if os.path.exists(product_file):
with open(product_file) as pf:
product = pf.read().strip()
try:
pid = int(product, 16)
result["usb_pid"] = pid
model_info = USB_PID_TO_MODEL.get(pid)
if model_info:
result["model_family"] = model_info[0]
result["generation"] = model_info[1]
except ValueError:
pass
# Read USB serial — on iPods this is the FireWire
# GUID (16 hex chars), not the Apple serial number.
serial_file = os.path.join(current, "serial")
if os.path.exists(serial_file):
with open(serial_file) as sf:
usb_serial = sf.read().strip()
if usb_serial:
clean = usb_serial.replace(" ", "")
if len(clean) == 16:
try:
bytes.fromhex(clean)
result["firewire_guid"] = clean.upper()
logger.debug("Linux sysfs: USB serial is FW GUID: %s", clean.upper())
except ValueError:
pass
break
current = os.path.dirname(current)
return result
def _linux_usb_info_from_bus_scan(base_disk: str) -> dict:
"""Scan ``/sys/bus/usb/devices/`` for an Apple device matching *base_disk*.
This is a last-resort fallback when neither udevadm nor the sysfs device
walk finds USB identity fields. It works by:
1. Following ``/sys/block/{base_disk}`` real path to find which USB bus
address the block device lives under (e.g. ``usb2/2-1``).
2. Scanning ``/sys/bus/usb/devices/`` for an entry whose ``idVendor``
is ``05ac`` (Apple) and whose real path is an ancestor of the block
device's real path.
3. Reading ``idProduct`` and ``serial`` from that USB device.
Returns a dict that may contain *usb_pid*, *firewire_guid*,
*model_family*, and *generation*.
"""
result: dict = {}
block_link = f"/sys/block/{base_disk}"
if not os.path.exists(block_link):
return result
block_real = os.path.realpath(block_link)
usb_devices_dir = "/sys/bus/usb/devices"
if not os.path.isdir(usb_devices_dir):
return result
try:
entries = os.listdir(usb_devices_dir)
except OSError:
return result
for entry in entries:
entry_path = os.path.join(usb_devices_dir, entry)
vendor_file = os.path.join(entry_path, "idVendor")
if not os.path.exists(vendor_file):
continue
try:
with open(vendor_file) as vf:
vendor = vf.read().strip()
except OSError:
continue
if vendor != "05ac":
continue
# Check if this USB device is an ancestor of the block device
usb_real = os.path.realpath(entry_path)
if not block_real.startswith(usb_real + "/"):
continue
# Found the Apple USB device that owns this disk
product_file = os.path.join(entry_path, "idProduct")
if os.path.exists(product_file):
try:
with open(product_file) as pf:
product = pf.read().strip()
pid = int(product, 16)
result["usb_pid"] = pid
model_info = USB_PID_TO_MODEL.get(pid)
if model_info:
result["model_family"] = model_info[0]
result["generation"] = model_info[1]
except (ValueError, OSError):
pass
serial_file = os.path.join(entry_path, "serial")
if os.path.exists(serial_file):
try:
with open(serial_file) as sf:
usb_serial = sf.read().strip().replace(" ", "")
if len(usb_serial) == 16:
bytes.fromhex(usb_serial)
result["firewire_guid"] = usb_serial.upper()
logger.debug("Linux USB bus scan: FW GUID: %s", usb_serial.upper())
except (ValueError, OSError):
pass
if result:
logger.debug("Linux USB bus scan: %s", result)
break
return result
def _probe_hardware_linux(mount_path: str) -> dict:
"""
Linux hardware probing via sysfs / udevadm / findmnt.
Traces the mount point → block device → USB device through multiple
strategies to extract the USB PID, serial number, and FireWire GUID.
Strategies (tried in order for each sub-task):
Block device lookup:
1. ``findmnt`` — handles paths with spaces and bind mounts
2. ``/proc/mounts`` with octal-escape decoding
3. ``lsblk --json``
USB identity extraction:
1. ``udevadm info`` on the partition device
2. ``udevadm info`` on the parent disk device (Arch/CachyOS may not
propagate USB properties to partition devices)
3. sysfs walk — manual traversal from block device to USB ancestor
4. USB bus scan — walk ``/sys/bus/usb/devices/`` for Apple devices
matching this block device
"""
import re as _re
result: dict = {}
try:
device = _linux_find_block_device(mount_path)
if not device:
logger.debug("Linux probe: could not resolve block device for %s", mount_path)
return result
# Get the base disk name (e.g., sdb from /dev/sdb1)
dev_name = os.path.basename(device)
base_disk = _re.sub(r"\d+$", "", dev_name) # sdb1 → sdb
# ── Strategy 1: udevadm info on partition ─────────────────
result = _linux_usb_info_from_udevadm(device)
# ── Strategy 2: udevadm info on parent disk ──────────────
# On Arch-based distros (CachyOS, Manjaro, EndeavourOS), udev
# rules may not propagate USB identity properties (ID_VENDOR_ID,
# ID_MODEL_ID, ID_SERIAL_SHORT) from the USB device to its
# partition children. Querying the parent disk directly works.
if not result and base_disk != dev_name:
result = _linux_usb_info_from_udevadm(f"/dev/{base_disk}")
# ── Strategy 3: sysfs walk ────────────────────────────────
if not result:
result = _linux_usb_info_from_sysfs(base_disk)
# ── Strategy 4: USB bus scan (last resort) ────────────────
if not result:
result = _linux_usb_info_from_bus_scan(base_disk)
except Exception as e:
logger.debug("Linux hardware probe failed: %s", e)
return result
def _identify_via_usb_for_drive(drive_letter: str) -> dict | None:
"""
Identify the iPod connected at a specific drive letter via WMI + USB registry.
Uses WMI to trace: drive letter → Win32_DiskDrive → PNPDeviceID
then cross-references the USBSTOR instance ID to the parent USB device
to get the actual PID for THIS specific device (not stale registry entries).
Returns dict with keys: firewire_guid, serial, firmware, usb_pid,
model_family, generation
"""
if sys.platform != "win32":
return None
import subprocess
result: dict = {}
# ── Step 1: Use WMI to get the disk PNPDeviceID for this drive letter ──
try:
# Query WMI to find the disk drive associated with this drive letter.
# Chain: LogicalDisk → Partition → DiskDrive
ps_cmd = (
f"$logdisk = Get-WmiObject Win32_LogicalDisk | "
f"Where-Object {{ $_.DeviceID -eq '{drive_letter}:' }}; "
f"if ($logdisk) {{ "
f" $part = Get-WmiObject -Query \"ASSOCIATORS OF "
f"{{Win32_LogicalDisk.DeviceID='$($logdisk.DeviceID)'}} "
f"WHERE AssocClass=Win32_LogicalDiskToPartition\"; "
f" if ($part) {{ "
f" $disk = Get-WmiObject -Query \"ASSOCIATORS OF "
f"{{Win32_DiskPartition.DeviceID='$($part.DeviceID)'}} "
f"WHERE AssocClass=Win32_DiskDriveToDiskPartition\"; "
f" if ($disk) {{ "
f" Write-Output \"PNP:$($disk.PNPDeviceID)\"; "
f" Write-Output \"SERIAL:$($disk.SerialNumber.Trim())\"; "
f" Write-Output \"MODEL:$($disk.Model)\" "
f" }} "
f" }} "
f"}}"
)
wmi_result = subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=10,
**_SP_KWARGS,
)
pnp_id = ""
for line in wmi_result.stdout.strip().splitlines():
line = line.strip()
if line.startswith("PNP:"):
pnp_id = line[4:]
elif line.startswith("SERIAL:"):
wmi_serial = line[7:].strip()
if wmi_serial:
# WMI disk serial for iPods is the FireWire GUID
# (16 hex chars), not the Apple serial.
wmi_clean = wmi_serial.replace(" ", "")
if len(wmi_clean) == 16:
try:
bytes.fromhex(wmi_clean)
result.setdefault("firewire_guid", wmi_clean.upper())
logger.debug("WMI: serial is FW GUID: %s", wmi_clean.upper())
except ValueError:
result["serial"] = wmi_serial
logger.debug("WMI: non-hex serial (Apple?): %s", wmi_serial)
else:
result["serial"] = wmi_serial
logger.debug("WMI: non-GUID serial (Apple?): %s", wmi_serial)
elif line.startswith("MODEL:"):
pass # Just confirms it's an iPod
if not pnp_id:
logger.debug("Drive %s: no WMI disk drive found", drive_letter)
return result if result else None
except Exception as e:
logger.debug("WMI query failed for drive %s: %s", drive_letter, e)
return None
# ── Step 2: Extract info from the USBSTOR PNPDeviceID ──
# Format varies:
# Simple: USBSTOR\DISK&VEN_APPLE&PROD_IPOD&REV_1.62\000A270018A1F847&0
# Composite: USBSTOR\DISK&VEN_APPLE&PROD_IPOD&REV_1.62\8&2F161EF4&0&000A2700138A422D&0
if "USBSTOR" in pnp_id.upper():
parts = pnp_id.split("\\")
if len(parts) >= 2:
device_desc = parts[1] if len(parts) > 1 else ""
instance_id = parts[2] if len(parts) > 2 else ""
# Extract firmware revision from "REV_x.xx"
if "REV_" in device_desc.upper():
rev_part = device_desc.upper().split("REV_")[-1]
result["firmware"] = rev_part
# Extract FireWire GUID from instance ID
guid = _extract_guid_from_instance_id(instance_id)
if guid:
result["firewire_guid"] = guid
logger.debug("WMI USBSTOR: FW GUID from instance ID: %s", guid)
# ── Step 3: Find the USB PID for THIS specific device ──
# Cross-reference the USBSTOR instance to its parent USB device.
# We use the extracted GUID (which is the USB iSerialNumber) to find
# the matching USB\VID_05AC&PID_xxxx\<guid> entry in the registry.
try:
import winreg
# Use the GUID as the cross-reference key (it appears as the USB
# device instance ID). Falls back to scanning all segments.
guid_for_match = result.get("firewire_guid", "")
if not guid_for_match and "\\" in pnp_id:
guid_for_match = _extract_guid_from_instance_id(
pnp_id.split("\\")[-1]
)
if guid_for_match:
usb_key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Enum\USB"
)
try:
k = 0
while True:
try:
subkey_name = winreg.EnumKey(usb_key, k)
k += 1
except OSError:
break
upper = subkey_name.upper()
if "VID_05AC" not in upper or "PID_" not in upper:
continue
# Skip composite interface sub-devices (MI_xx)
if "MI_" in upper:
continue
# Check if THIS USB device has our USBSTOR instance ID
try:
pid_key = winreg.OpenKey(usb_key, subkey_name)
except OSError:
continue
try:
m = 0
while True:
try:
usb_instance = winreg.EnumKey(pid_key, m)
m += 1
except OSError:
break
# Match the USBSTOR GUID to the USB instance
if guid_for_match.upper() in usb_instance.upper():
pid_str = upper.split("PID_")[1][:4]
try:
pid = int(pid_str, 16)
result["usb_pid"] = pid
model_info = USB_PID_TO_MODEL.get(pid)
if model_info:
result["model_family"] = model_info[0]
result["generation"] = model_info[1]
logger.debug(
"Drive %s: matched USB PID 0x%04X via "
"GUID %s",
drive_letter, pid, guid_for_match,
)
except ValueError:
pass
break # Found our device
finally:
winreg.CloseKey(pid_key)
# Stop scanning once we found our match
if "usb_pid" in result:
break
finally:
winreg.CloseKey(usb_key)
except OSError:
pass
return result if result else None
# ── Direct IOCTL detection (no WMI / PowerShell) ──────────────────────────
# Windows constants for CreateFileW / DeviceIoControl
_GENERIC_READ = 0x80000000
_FILE_SHARE_READ = 0x01
_FILE_SHARE_WRITE = 0x02
_OPEN_EXISTING = 3
_IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400
def _identify_via_direct_ioctl(drive_letter: str) -> dict | None:
"""
Query the USB storage device directly via IOCTL_STORAGE_QUERY_PROPERTY.
Opens the drive handle (``\\\\.\\X:``) and sends a STORAGE_PROPERTY_QUERY
for StorageDeviceProperty. Under the hood Windows issues a SCSI INQUIRY
to the device and returns the parsed result in a STORAGE_DEVICE_DESCRIPTOR.
This bypasses WMI, PowerShell, and the USB registry entirely — the
response comes straight from the device firmware.
Returns a dict with: vendor, product, serial, firmware, bus_type,
model_family, generation (if PID can be inferred).
Only works on Windows (requires kernel32 / DeviceIoControl).
"""
if sys.platform != "win32":
return None
_setup_win32_prototypes()
result: dict = {}
path = f"\\\\.\\{drive_letter}:"
handle = ctypes.windll.kernel32.CreateFileW( # type: ignore[attr-defined]
path,
_GENERIC_READ,
_FILE_SHARE_READ | _FILE_SHARE_WRITE,
None,
_OPEN_EXISTING,
0,
None,
)
INVALID = ctypes.c_void_p(-1).value
if handle == INVALID:
logger.debug("Direct IOCTL: cannot open %s (access denied?)", path)
return None
try:
# STORAGE_PROPERTY_QUERY:
# PropertyId = 0 (StorageDeviceProperty)
# QueryType = 0 (PropertyStandardQuery)
# AdditionalParameters[1] = 0
query = struct.pack("<III", 0, 0, 0) # 12 bytes
buf_size = 1024
out_buf = (ctypes.c_ubyte * buf_size)()
returned = wt.DWORD(0)
ok = ctypes.windll.kernel32.DeviceIoControl( # type: ignore[attr-defined]
handle,
_IOCTL_STORAGE_QUERY_PROPERTY,
query,
len(query),
out_buf,
buf_size,
ctypes.byref(returned),
None,
)
if not ok:
err = ctypes.get_last_error()
logger.debug("Direct IOCTL: DeviceIoControl failed on %s (err=%d)",
path, err)
return None
data = bytes(out_buf[: returned.value])
if len(data) < 36:
logger.debug("Direct IOCTL: response too short (%d bytes)", len(data))
return None
# Parse STORAGE_DEVICE_DESCRIPTOR
# 0: Version (DWORD)
# 4: Size (DWORD)
# 8: DeviceType (BYTE)
# 9: DeviceTypeMod (BYTE)
# 10: RemovableMedia (BOOLEAN)
# 11: CommandQueueing (BOOLEAN)
# 12: VendorIdOffset (DWORD)
# 16: ProductIdOffset(DWORD)
# 20: ProductRevisionOffset (DWORD)
# 24: SerialNumberOffset (DWORD)
# 28: BusType (DWORD) — STORAGE_BUS_TYPE enum
# 32: RawPropertiesLength (DWORD)
# 36: RawDeviceProperties[1] (variable)
def _read_str(offset_pos: int) -> str:
if offset_pos + 4 > len(data):
return ""
off = struct.unpack_from("<I", data, offset_pos)[0]
if off == 0 or off >= len(data):
return ""
# Find null terminator
end = off
while end < len(data) and data[end] != 0:
end += 1
return data[off:end].decode("ascii", errors="replace").strip()
vendor = _read_str(12)
product = _read_str(16)
revision = _read_str(20)
serial = _read_str(24)
bus_type = struct.unpack_from("<I", data, 28)[0] if len(data) >= 32 else -1
removable = bool(data[10]) if len(data) > 10 else False
logger.debug(
"Direct IOCTL %s: vendor=%r product=%r revision=%r serial=%r "
"bus_type=%d removable=%s",
drive_letter, vendor, product, revision, serial, bus_type, removable,
)
# Validate it's actually an Apple iPod
if vendor.lower() not in ("apple", "apple inc.", "apple inc"):
logger.debug("Direct IOCTL: vendor is %r, not Apple — skipping",
vendor)
return None
result["vendor"] = vendor
result["product"] = product
result["bus_type"] = bus_type
if revision:
result["firmware"] = revision
if serial:
# The IOCTL serial for iPods is typically the FireWire GUID
# (16 hex chars), NOT the Apple serial number. Store as
# firewire_guid only — the real Apple serial comes from SysInfo.
clean = serial.replace(" ", "").strip()
if len(clean) == 16:
try:
bytes.fromhex(clean)
result["firewire_guid"] = clean.upper()
logger.debug("Direct IOCTL: serial is FW GUID: %s", clean.upper())
except ValueError:
# Not a hex string — might be a real Apple serial
result["serial"] = serial
logger.debug("Direct IOCTL: non-hex serial (Apple?): %s", serial)
else:
# Non-16-char serial — could be an actual Apple serial
result["serial"] = serial
logger.debug("Direct IOCTL: non-GUID serial (Apple?): %s", serial)
finally:
ctypes.windll.kernel32.CloseHandle(handle) # type: ignore[attr-defined]
# ── Walk the PnP device tree to get FireWire GUID and USB PID ──
# The SCSI layer gives us vendor/product/serial/firmware, but the
# FireWire GUID (needed for hash generation) and the USB PID live
# in the PnP device tree above the SCSI device.
tree_info = _walk_device_tree(drive_letter)
if tree_info:
for key in (
"usb_vid",
"usbstor_instance_id",
"usb_parent_instance_id",
"usb_grandparent_instance_id",
):
if tree_info.get(key):
result[key] = tree_info[key]
if tree_info.get("firewire_guid"):
result["firewire_guid"] = tree_info["firewire_guid"]
logger.debug(
"Drive %s: FW GUID from device tree: %s",
drive_letter, tree_info["firewire_guid"],
)
if tree_info.get("usb_pid"):
result["usb_pid"] = tree_info["usb_pid"]
if tree_info.get("model_family"):
result.setdefault("model_family", tree_info["model_family"])
if tree_info.get("generation"):
result.setdefault("generation", tree_info["generation"])
return result if result else None
# ── PnP device tree walk via SetupAPI + cfgmgr32 ──────────────────────────
# These constants / structs are scoped to Windows-only. The functions that
# use them already guard with ``sys.platform != "win32"``.
_IOCTL_STORAGE_GET_DEVICE_NUMBER = 0x002D1080
_DIGCF_PRESENT = 0x02
_DIGCF_DEVICEINTERFACE = 0x10
_CR_SUCCESS = 0
class _GUID(ctypes.Structure):
_fields_ = [
("Data1", ctypes.c_ulong),
("Data2", ctypes.c_ushort),
("Data3", ctypes.c_ushort),
("Data4", ctypes.c_ubyte * 8),
]
class _SP_DEVICE_INTERFACE_DATA(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_ulong),
("InterfaceClassGuid", _GUID),
("Flags", ctypes.c_ulong),
("Reserved", ctypes.POINTER(ctypes.c_ulong)),
]
class _SP_DEVINFO_DATA(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_ulong),
("ClassGuid", _GUID),
("DevInst", ctypes.c_ulong),
("Reserved", ctypes.POINTER(ctypes.c_ulong)),
]
class _STORAGE_DEVICE_NUMBER(ctypes.Structure):
_fields_ = [
("DeviceType", ctypes.c_ulong),
("DeviceNumber", ctypes.c_ulong),
("PartitionNumber", ctypes.c_ulong),
]
# {53F56307-B6BF-11D0-94F2-00A0C91EFB8B}
_GUID_DEVINTERFACE_DISK = _GUID(
0x53F56307, 0xB6BF, 0x11D0,
(ctypes.c_ubyte * 8)(0x94, 0xF2, 0x00, 0xA0, 0xC9, 0x1E, 0xFB, 0x8B),
)
def _setup_win32_prototypes() -> None:
"""
Declare proper argtypes/restype for Win32 functions used by the direct
backend. Without this, ctypes defaults to ``c_int`` return values which
**truncate 64-bit handles** on 64-bit Windows — a silent, fatal bug.
Called once on first use; subsequent calls are no-ops.
"""
if getattr(_setup_win32_prototypes, "_done", False):
return
_setup_win32_prototypes._done = True # type: ignore[attr-defined]
k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
sa = ctypes.windll.setupapi # type: ignore[attr-defined]
cm = ctypes.windll.cfgmgr32 # type: ignore[attr-defined]
# ── kernel32 ───────────────────────────────────────────────────────
k32.CreateFileW.argtypes = [
wt.LPCWSTR, wt.DWORD, wt.DWORD, ctypes.c_void_p,
wt.DWORD, wt.DWORD, wt.HANDLE,
]
k32.CreateFileW.restype = ctypes.c_void_p # HANDLE (pointer-width)
k32.DeviceIoControl.argtypes = [
ctypes.c_void_p, wt.DWORD,
ctypes.c_void_p, wt.DWORD,
ctypes.c_void_p, wt.DWORD,
ctypes.POINTER(wt.DWORD), ctypes.c_void_p,
]
k32.DeviceIoControl.restype = wt.BOOL
k32.CloseHandle.argtypes = [ctypes.c_void_p]
k32.CloseHandle.restype = wt.BOOL
# ── setupapi ───────────────────────────────────────────────────────
sa.SetupDiGetClassDevsW.argtypes = [
ctypes.c_void_p, ctypes.c_wchar_p, wt.HWND, wt.DWORD,
]
sa.SetupDiGetClassDevsW.restype = ctypes.c_void_p # HDEVINFO
sa.SetupDiEnumDeviceInterfaces.argtypes = [
ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_void_p, wt.DWORD,
ctypes.c_void_p,
]
sa.SetupDiEnumDeviceInterfaces.restype = wt.BOOL
sa.SetupDiGetDeviceInterfaceDetailW.argtypes = [
ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_void_p, wt.DWORD,
ctypes.POINTER(wt.DWORD), ctypes.c_void_p,
]
sa.SetupDiGetDeviceInterfaceDetailW.restype = wt.BOOL
sa.SetupDiDestroyDeviceInfoList.argtypes = [ctypes.c_void_p]
sa.SetupDiDestroyDeviceInfoList.restype = wt.BOOL
# ── cfgmgr32 ──────────────────────────────────────────────────────
cm.CM_Get_Device_ID_Size.argtypes = [
ctypes.POINTER(ctypes.c_ulong), ctypes.c_ulong, ctypes.c_ulong,
]
cm.CM_Get_Device_ID_Size.restype = ctypes.c_ulong
cm.CM_Get_Device_IDW.argtypes = [
ctypes.c_ulong, ctypes.c_wchar_p, ctypes.c_ulong, ctypes.c_ulong,
]
cm.CM_Get_Device_IDW.restype = ctypes.c_ulong
cm.CM_Get_Parent.argtypes = [
ctypes.POINTER(ctypes.c_ulong), ctypes.c_ulong, ctypes.c_ulong,
]
cm.CM_Get_Parent.restype = ctypes.c_ulong
def _walk_device_tree(drive_letter: str) -> dict:
"""
Walk the Windows PnP device tree from a volume to its USB ancestor.
Uses only Win32 APIs (SetupAPI + cfgmgr32) — no WMI, no PowerShell:
Volume (``\\\\.\\D:``)
→ ``IOCTL_STORAGE_GET_DEVICE_NUMBER`` → DeviceNumber N
→ Enumerate ``GUID_DEVINTERFACE_DISK`` interfaces
→ Match by DeviceNumber → get ``DevInst``
→ ``CM_Get_Device_ID`` → USBSTOR instance ID (contains **FireWire GUID**)
→ ``CM_Get_Parent`` → USB device ID (contains **PID**)
Returns dict with any of: ``firewire_guid``, ``usb_pid``,
``model_family``, ``generation``.
"""
if sys.platform != "win32":
return {}
_setup_win32_prototypes()
result: dict = {}
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
setupapi = ctypes.windll.setupapi # type: ignore[attr-defined]
cfgmgr32 = ctypes.windll.cfgmgr32 # type: ignore[attr-defined]
INVALID = ctypes.c_void_p(-1).value # 0xFFFFFFFFFFFFFFFF on 64-bit
# ── Step 1: Get the physical device number for this volume ──────────
vol_path = f"\\\\.\\{drive_letter}:"
vol_handle = kernel32.CreateFileW(
vol_path, _GENERIC_READ, _FILE_SHARE_READ | _FILE_SHARE_WRITE,
None, _OPEN_EXISTING, 0, None,
)
if vol_handle == INVALID:
return result
try:
sdn = _STORAGE_DEVICE_NUMBER()
returned = wt.DWORD()
ok = kernel32.DeviceIoControl(
vol_handle, _IOCTL_STORAGE_GET_DEVICE_NUMBER,
None, 0, ctypes.byref(sdn), ctypes.sizeof(sdn),
ctypes.byref(returned), None,
)
if not ok:
return result
target_dev_num = sdn.DeviceNumber
finally:
kernel32.CloseHandle(vol_handle)
logger.debug("Drive %s: physical device number = %d",
drive_letter, target_dev_num)
# ── Step 2: Enumerate present disk interfaces, find matching one ───
hDevInfo = setupapi.SetupDiGetClassDevsW(
ctypes.byref(_GUID_DEVINTERFACE_DISK), None, None,
_DIGCF_PRESENT | _DIGCF_DEVICEINTERFACE,
)
if hDevInfo == INVALID:
return result
target_devinst = 0
try:
idx = 0
while True:
iface = _SP_DEVICE_INTERFACE_DATA()
iface.cbSize = ctypes.sizeof(_SP_DEVICE_INTERFACE_DATA)
if not setupapi.SetupDiEnumDeviceInterfaces(
hDevInfo, None, ctypes.byref(_GUID_DEVINTERFACE_DISK),
idx, ctypes.byref(iface),
):
break
idx += 1
# First call: get required buffer size (expected to fail with
# ERROR_INSUFFICIENT_BUFFER — that's fine, we just need the size)
required = wt.DWORD()
devinfo = _SP_DEVINFO_DATA()
devinfo.cbSize = ctypes.sizeof(_SP_DEVINFO_DATA)
setupapi.SetupDiGetDeviceInterfaceDetailW(
hDevInfo, ctypes.byref(iface), None, 0,
ctypes.byref(required), ctypes.byref(devinfo),
)
if required.value == 0:
continue
# Allocate and fill SP_DEVICE_INTERFACE_DETAIL_DATA_W.
# The struct has a DWORD cbSize followed by a WCHAR[] path.
# cbSize must be set to 8 on 64-bit Windows, 6 on 32-bit.
buf_size = required.value
detail_buf = (ctypes.c_byte * buf_size)()
cb_size = 8 if ctypes.sizeof(ctypes.c_void_p) == 8 else 6
struct.pack_into("<I", detail_buf, 0, cb_size)
devinfo2 = _SP_DEVINFO_DATA()
devinfo2.cbSize = ctypes.sizeof(_SP_DEVINFO_DATA)
if not setupapi.SetupDiGetDeviceInterfaceDetailW(
hDevInfo, ctypes.byref(iface), detail_buf, buf_size,
None, ctypes.byref(devinfo2),
):
continue
# Device path is a null-terminated wide string at offset 4
device_path = ctypes.wstring_at(
ctypes.addressof(detail_buf) + 4,
)
# Open the disk device and compare its device number
dev_handle = kernel32.CreateFileW(
device_path, 0, _FILE_SHARE_READ | _FILE_SHARE_WRITE,
None, _OPEN_EXISTING, 0, None,
)
if dev_handle == INVALID:
continue
try:
sdn2 = _STORAGE_DEVICE_NUMBER()
returned2 = wt.DWORD()
ok2 = kernel32.DeviceIoControl(
dev_handle, _IOCTL_STORAGE_GET_DEVICE_NUMBER,
None, 0, ctypes.byref(sdn2), ctypes.sizeof(sdn2),
ctypes.byref(returned2), None,
)
finally:
kernel32.CloseHandle(dev_handle)
if ok2 and sdn2.DeviceNumber == target_dev_num:
target_devinst = devinfo2.DevInst
break
finally:
setupapi.SetupDiDestroyDeviceInfoList(hDevInfo)
if not target_devinst:
logger.debug("Drive %s: no matching disk in device tree",
drive_letter)
return result
# ── Step 3: Get USBSTOR instance ID → extract FireWire GUID ────────
# e.g. "USBSTOR\DISK&VEN_APPLE&PROD_IPOD&REV_1.62\000A270018A1F847&0"
id_len = ctypes.c_ulong()
if cfgmgr32.CM_Get_Device_ID_Size(
ctypes.byref(id_len), target_devinst, 0,
) != _CR_SUCCESS:
return result
dev_id_buf = ctypes.create_unicode_buffer(id_len.value + 1)
if cfgmgr32.CM_Get_Device_IDW(
target_devinst, dev_id_buf, id_len.value + 1, 0,
) != _CR_SUCCESS:
return result
usbstor_id = dev_id_buf.value
result["usbstor_instance_id"] = usbstor_id
logger.debug("Drive %s: USBSTOR instance = %s", drive_letter, usbstor_id)
if "USBSTOR" in usbstor_id.upper():
parts = usbstor_id.split("\\")
if len(parts) >= 3:
guid = _extract_guid_from_instance_id(parts[2])
if guid:
result["firewire_guid"] = guid
logger.debug(
"Drive %s: FW GUID from USBSTOR instance: %s",
drive_letter, guid,
)
# ── Step 4: Walk up to USB parent → extract PID ────────────────────
# For simple USB devices the parent is the USB device node:
# USB\VID_05AC&PID_1260\000A270018A1F847
# For composite USB devices the immediate parent is an interface node:
# USB\VID_05AC&PID_1261&MI_00\7&2551D7E5&0
# In both cases we get the PID. For composite devices, walk up one
# more level to reach the actual USB device node if we still need
# the GUID (fallback if USBSTOR extraction didn't yield it).
parent = ctypes.c_ulong()
if cfgmgr32.CM_Get_Parent(
ctypes.byref(parent), target_devinst, 0,
) == _CR_SUCCESS:
id_len2 = ctypes.c_ulong()
if cfgmgr32.CM_Get_Device_ID_Size(
ctypes.byref(id_len2), parent.value, 0,
) == _CR_SUCCESS:
parent_buf = ctypes.create_unicode_buffer(id_len2.value + 1)
if cfgmgr32.CM_Get_Device_IDW(
parent.value, parent_buf, id_len2.value + 1, 0,
) == _CR_SUCCESS:
usb_id = parent_buf.value
result["usb_parent_instance_id"] = usb_id
logger.debug("Drive %s: USB parent = %s",
drive_letter, usb_id)
upper_id = usb_id.upper()
if "VID_" in upper_id:
vid_str = upper_id.split("VID_")[1][:4]
try:
result["usb_vid"] = int(vid_str, 16)
except ValueError:
pass
if "PID_" in upper_id:
pid_str = upper_id.split("PID_")[1][:4]
try:
pid = int(pid_str, 16)
result["usb_pid"] = pid
model_info = USB_PID_TO_MODEL.get(pid)
if model_info:
result["model_family"] = model_info[0]
result["generation"] = model_info[1]
except ValueError:
pass
# Composite device: parent is USB\...&MI_xx\... (interface)
# Walk up one more level to the real USB device node.
# Its instance ID will have the GUID as a simple segment.
if "MI_" in upper_id and not result.get("firewire_guid"):
grandparent = ctypes.c_ulong()
if cfgmgr32.CM_Get_Parent(
ctypes.byref(grandparent), parent.value, 0,
) == _CR_SUCCESS:
gp_len = ctypes.c_ulong()
if cfgmgr32.CM_Get_Device_ID_Size(
ctypes.byref(gp_len), grandparent.value, 0,
) == _CR_SUCCESS:
gp_buf = ctypes.create_unicode_buffer(
gp_len.value + 1
)
if cfgmgr32.CM_Get_Device_IDW(
grandparent.value, gp_buf,
gp_len.value + 1, 0,
) == _CR_SUCCESS:
gp_id = gp_buf.value
result["usb_grandparent_instance_id"] = gp_id
logger.debug(
"Drive %s: USB grandparent = %s",
drive_letter, gp_id,
)
gp_parts = gp_id.split("\\")
if len(gp_parts) >= 3:
gp_guid = _extract_guid_from_instance_id(
gp_parts[2]
)
if gp_guid:
result["firewire_guid"] = gp_guid
logger.debug(
"Drive %s: FW GUID from USB "
"grandparent: %s",
drive_letter, gp_guid,
)
return result
# ── Unified probing functions ──────────────────────────────────────────────
def _probe_hardware(mount_path: str, mount_name: str) -> dict:
"""
Phase 1: Hardware probing — query the USB device for identification.
Platform-specific:
- **Windows**: Direct IOCTL + device tree walk, with WMI fallback.
- **macOS**: system_profiler SPUSBDataType to find the Apple USB device.
- **Linux**: sysfs traversal from block device to USB device.
Returns a dict that may contain any of:
vendor, product, serial, firmware, bus_type, firewire_guid,
usb_pid, model_family, generation, _sources
"""
result: dict = {}
_hw_method = ""
logger.debug(
"Hardware probe start: mount=%s display=%s platform=%s",
mount_path,
mount_name,
sys.platform,
)
if sys.platform == "win32":
# On Windows, mount_name is "D:" — extract the drive letter
drive_letter = mount_name[0] if mount_name and mount_name[0].isalpha() else ""
if not drive_letter:
logger.debug("Hardware probe result: mount=%s no drive letter", mount_path)
return result
# ── Primary: Direct IOCTL + device tree (fast, no subprocess) ──
ioctl_info = _identify_via_direct_ioctl(drive_letter)
if ioctl_info:
result.update(ioctl_info)
_hw_method = "ioctl"
logger.debug("Hardware probe (direct): %s", result)
# ── Fallback: WMI (only if direct gave us nothing useful) ──────
if not result:
logger.debug(
"Direct probe failed for drive %s, falling back to WMI",
drive_letter,
)
wmi_info = _identify_via_usb_for_drive(drive_letter)
if wmi_info:
result.update(wmi_info)
_hw_method = "wmi"
logger.debug("Hardware probe (WMI fallback): %s", result)
elif sys.platform == "darwin":
result = _probe_hardware_macos(mount_path)
_hw_method = "ioreg"
if result:
logger.debug("Hardware probe (macOS): %s", result)
else:
result = _probe_hardware_linux(mount_path)
_hw_method = "sysfs"
if result:
logger.debug("Hardware probe (Linux): %s", result)
# Annotate per-field data sources for authority tracking
if result and _hw_method:
sources = result.setdefault("_sources", {})
if result.get("firewire_guid"):
# On Windows, FW GUID comes from device tree walk specifically
sources["firewire_guid"] = (
"device_tree" if _hw_method in ("ioctl", "wmi") else _hw_method
)
if result.get("serial"):
sources["serial"] = _hw_method
if result.get("firmware"):
sources["firmware"] = _hw_method
if result.get("usb_pid"):
sources["usb_pid"] = (
"device_tree" if _hw_method in ("ioctl", "wmi") else _hw_method
)
logger.debug(
"Hardware probe result: mount=%s method=%s identity=[%s] sources=[%s]",
mount_path,
_hw_method or "none",
format_fields(result, IDENTITY_FIELDS),
format_sources(result.get("_sources", {}), SOURCE_FIELDS),
)
return result
def _probe_filesystem(ipod_path: str) -> dict:
"""
Phase 2: Filesystem probing — read on-device files for identification.
Reads SysInfo/SysInfoExtended and the iTunesDB header. All file reads
are independent and their results are merged.
Returns a dict that may contain any of:
model_number, model_family, generation, capacity, color,
serial, firewire_guid, firmware, hashing_scheme
"""
result: dict = {}
# ── SysInfo / SysInfoExtended ──────────────────────────────────────
sysinfo = _identify_via_sysinfo(ipod_path)
if sysinfo:
result.update(sysinfo)
# ── iTunesDB header (hashing_scheme) ───────────────────────────────
hash_info = _identify_via_hashing_scheme(ipod_path)
if hash_info:
# Only take hashing_scheme; model_family from this source is low-confidence
result["hashing_scheme"] = hash_info.get("hashing_scheme", -1)
result.setdefault("_sources", {}).setdefault("hashing_scheme", "itunes")
# Store the hash-inferred family/gen separately so Phase 3 can use
# them as a last resort without overriding higher-confidence sources.
if hash_info.get("model_family"):
result["hash_model_family"] = hash_info["model_family"]
result["hash_generation"] = hash_info.get("generation", "")
logger.debug(
"Filesystem probe result: mount=%s meta=[%s] identity=[%s] caps=[%s] "
"sources=[%s]",
ipod_path,
format_fields(result, _PROBE_META_FIELDS, include_false=True),
format_fields(result, IDENTITY_FIELDS),
format_fields(result, CAPABILITY_FIELDS, include_false=True),
format_sources(result.get("_sources", {}), SOURCE_FIELDS),
)
return result
def _log_model_resolution(resolved: dict, disk_size_gb: float) -> None:
logger.debug(
"Model resolution result: method=%s disk=%.1fGB identity=[%s] "
"sources=[%s] conflicts=[%s]",
resolved.get("identification_method", "unknown"),
disk_size_gb,
format_fields(resolved, IDENTITY_FIELDS),
format_sources(resolved.get("_sources", {}), SOURCE_FIELDS),
format_conflicts(resolved.get("_conflicts", [])),
)
def _resolve_model(
hw: dict,
fs: dict,
disk_size_gb: float,
) -> dict:
"""
Phase 3: Model resolution — synthesise a final identification from all
collected data with clear per-field priority.
Returns the resolved fields: model_number, model_family, generation,
capacity, color, firewire_guid, serial, firmware, usb_pid, hashing_scheme,
identification_method, _sources.
"""
from .lookup import get_model_info, usb_pid_identity_conflicts
resolved: dict = {}
hw_sources = hw.get("_sources", {})
fs_sources = fs.get("_sources", {})
sources: dict[str, str] = {}
resolved["_sources"] = sources # reference — mutations visible in resolved
conflicts: list[dict] = []
resolved["_conflicts"] = conflicts
extra_fields = (
"family_id",
"updater_family_id",
"product_type",
"usb_vid",
"usb_serial",
"usbstor_instance_id",
"usb_parent_instance_id",
"usb_grandparent_instance_id",
"scsi_vendor",
"scsi_product",
"scsi_revision",
"connected_bus",
"volume_format",
"db_version",
"shadow_db_version",
"uses_sqlite_db",
"supports_sparse_artwork",
"max_tracks",
"max_file_size_gb",
"max_transfer_speed",
"podcasts_supported",
"voice_memos_supported",
"audio_codecs",
"power_information",
"apple_drm_version",
"artwork_formats",
"photo_formats",
"chapter_image_formats",
)
for field in extra_fields:
hw_value = hw.get(field)
fs_value = fs.get(field)
if hw_value not in (None, "", b"", {}, []):
resolved[field] = hw_value
sources[field] = hw_sources.get(field, "hardware")
if fs_value not in (None, "", b"", {}, []) and field not in resolved:
resolved[field] = fs_value
sources[field] = fs_sources.get(field, "sysinfo_extended")
# ── FireWire GUID ──────────────────────────────────────────────────
# Priority: device tree > SysInfoExtended/SysInfo > IOCTL serial
# (The device tree USBSTOR instance is the most authoritative because
# it's guaranteed to be for the currently-connected device at this
# specific drive letter. SysInfo can be stale or missing.)
if hw.get("firewire_guid"):
resolved["firewire_guid"] = hw["firewire_guid"]
sources["firewire_guid"] = hw_sources.get("firewire_guid", "hardware")
elif fs.get("firewire_guid"):
resolved["firewire_guid"] = fs["firewire_guid"]
sources["firewire_guid"] = fs_sources.get("firewire_guid", "sysinfo")
else:
resolved["firewire_guid"] = ""
# ── Serial (Apple serial number, NOT the USB/FireWire GUID) ────────
# Only the filesystem layer (SysInfo pszSerialNumber) provides the real
# Apple serial. Hardware probing returns the USB serial which is always
# the FireWire GUID on iPods — that's stored in firewire_guid above.
fs_serial = fs.get("serial", "")
hw_serial = hw.get("serial", "") # rare: non-GUID serial from IOCTL
if fs_serial and not fs_serial.startswith("RAND"):
resolved["serial"] = fs_serial
sources["serial"] = fs_sources.get("serial", "sysinfo")
elif hw_serial and not hw_serial.startswith("RAND"):
resolved["serial"] = hw_serial
sources["serial"] = hw_sources.get("serial", "hardware")
else:
resolved["serial"] = ""
# ── Firmware ───────────────────────────────────────────────────────
# Priority: IOCTL revision > SysInfo visibleBuildID
if hw.get("firmware"):
resolved["firmware"] = hw["firmware"]
sources["firmware"] = hw_sources.get("firmware", "hardware")
elif fs.get("firmware"):
resolved["firmware"] = fs["firmware"]
sources["firmware"] = fs_sources.get("firmware", "sysinfo")
else:
resolved["firmware"] = ""
# ── USB PID ────────────────────────────────────────────────────────
resolved["usb_pid"] = hw.get("usb_pid", 0)
if resolved["usb_pid"]:
sources.setdefault("usb_pid", hw_sources.get("usb_pid", "hardware"))
# ── Hashing scheme ─────────────────────────────────────────────────
resolved["hashing_scheme"] = fs.get("hashing_scheme", -1)
# ── Model identification (layered, highest-confidence wins) ────────
#
# Layers 1 and 2 are evaluated together so they can cross-check each
# other. When they agree the SysInfo result is used (preserving its
# provenance). When they DISAGREE the serial wins: the last-3 suffix
# is a manufacturer-encoded identifier that is much harder to corrupt
# than the NVRAM-stored ModelNumStr, which can be wrong after a botched
# restore, firmware flash, or logic-board swap (e.g. a device whose
# NVRAM reports M9787/4th-Gen but whose serial encodes MA452/5th-Gen).
#
# Layer 1: SysInfo ModelNumStr → IPOD_MODELS
sysinfo_model = fs.get("model_number", "")
sysinfo_mi: tuple | None = None
if sysinfo_model:
sysinfo_mi = get_model_info(sysinfo_model)
# Layer 2: Serial last-3-char → IPOD_MODELS
serial = resolved["serial"]
serial_info: dict | None = None
if serial:
serial_info = _identify_via_serial_lookup(serial)
pid_family = hw.get("model_family", "")
pid_gen = hw.get("generation", "")
if sysinfo_mi and pid_family and usb_pid_identity_conflicts(
sysinfo_mi[0],
sysinfo_mi[1],
pid_family,
pid_gen,
):
logger.warning(
"_resolve_model: SysInfo ModelNumStr %s (%s %s) conflicts "
"with live USB PID identity %s %s; ignoring cached model",
sysinfo_model,
sysinfo_mi[0],
sysinfo_mi[1],
pid_family,
pid_gen or "(generation unknown)",
)
conflicts.append({
"field": "model_number",
"winner": "usb_pid",
"rejected_source": fs_sources.get("model_number", "sysinfo"),
"rejected_value": sysinfo_model,
"reason": "model conflicts with live USB PID identity",
})
sysinfo_model = ""
sysinfo_mi = None
if serial_info and pid_family and usb_pid_identity_conflicts(
serial_info.get("model_family", ""),
serial_info.get("generation", ""),
pid_family,
pid_gen,
):
logger.warning(
"_resolve_model: serial last-3 '%s' resolves to %s %s, "
"which conflicts with live USB PID identity %s %s; ignoring "
"cached serial-derived model",
serial[-3:],
serial_info.get("model_family", "?"),
serial_info.get("generation", "?"),
pid_family,
pid_gen or "(generation unknown)",
)
conflicts.append({
"field": "model_number",
"winner": "usb_pid",
"rejected_source": sources.get("serial", "serial_lookup"),
"rejected_value": serial_info.get("model_number", ""),
"reason": "serial-derived model conflicts with live USB PID identity",
})
serial_info = None
# Cross-check: if both resolved and they disagree, serial wins.
_use_serial = False
if sysinfo_mi and serial_info:
sr_model = serial_info.get("model_number", "")
if sr_model and sr_model != sysinfo_model:
logger.warning(
"_resolve_model: SysInfo ModelNumStr %s (%s %s) conflicts "
"with serial last-3 '%s'%s (%s %s); preferring serial "
"(USB PID family: %s)",
sysinfo_model, sysinfo_mi[0], sysinfo_mi[1],
serial[-3:], sr_model,
serial_info.get("model_family", "?"),
serial_info.get("generation", "?"),
hw.get("model_family", "unknown"),
)
conflicts.append({
"field": "model_number",
"winner": sources.get("serial", "serial_lookup"),
"rejected_source": fs_sources.get("model_number", "sysinfo"),
"rejected_value": sysinfo_model,
"reason": "SysInfo model conflicts with Apple serial suffix",
})
_use_serial = True
elif serial_info and not sysinfo_mi:
_use_serial = True
if sysinfo_mi and not _use_serial:
resolved["model_number"] = sysinfo_model
resolved["model_family"] = sysinfo_mi[0]
resolved["generation"] = sysinfo_mi[1]
resolved["capacity"] = sysinfo_mi[2]
resolved["color"] = sysinfo_mi[3]
resolved["identification_method"] = "sysinfo"
_mn_src = fs_sources.get("model_number", "sysinfo")
sources["model_number"] = _mn_src
sources.setdefault("model_family", _mn_src)
sources.setdefault("generation", _mn_src)
sources.setdefault("capacity", _mn_src)
sources.setdefault("color", _mn_src)
_log_model_resolution(resolved, disk_size_gb)
return resolved
if _use_serial:
serial_info = serial_info or {}
resolved["serial"] = serial
resolved["model_number"] = serial_info.get("model_number", "")
resolved["model_family"] = serial_info.get("model_family", "iPod")
resolved["generation"] = serial_info.get("generation", "")
resolved["capacity"] = serial_info.get("capacity", "")
resolved["color"] = serial_info.get("color", "")
resolved["identification_method"] = "serial"
# Derived fields inherit the serial's authority source.
_serial_src = sources.get("serial", "serial_lookup")
sources["model_number"] = "serial_lookup"
sources.setdefault("model_family", _serial_src)
sources.setdefault("generation", _serial_src)
sources.setdefault("capacity", _serial_src)
sources.setdefault("color", _serial_src)
_log_model_resolution(resolved, disk_size_gb)
return resolved
# Layer 3: USB PID → family/generation (coarse)
# No disk-size rejection — modded iPods often have non-stock storage.
pid = resolved["usb_pid"]
pid_family = hw.get("model_family", "")
pid_gen = hw.get("generation", "")
if pid and pid_family:
resolved["model_family"] = pid_family
resolved["generation"] = pid_gen
resolved["identification_method"] = "usb_pid"
sources.setdefault("model_family", "usb_pid")
if pid_gen:
sources.setdefault("generation", "usb_pid")
# Layer 4: Hashing scheme → generation class (coarsest)
if resolved.get("model_family", "iPod") == "iPod":
hash_family = fs.get("hash_model_family")
if hash_family and hash_family != "iPod":
resolved.setdefault("model_family", hash_family)
resolved.setdefault("generation", fs.get("hash_generation", ""))
resolved["identification_method"] = "hashing"
# Defaults for anything not yet resolved
resolved.setdefault("model_number", sysinfo_model or "")
resolved.setdefault("model_family", "iPod")
resolved.setdefault("generation", "")
resolved.setdefault("capacity", "")
resolved.setdefault("color", "")
resolved.setdefault("identification_method", "filesystem")
_log_model_resolution(resolved, disk_size_gb)
return resolved
def _identify_via_sysinfo(ipod_path: str) -> dict | None:
"""Try to identify via SysInfo / SysInfoExtended files."""
result: dict = {}
result["_sources"] = {}
# Try SysInfoExtended first
sie_path = os.path.join(ipod_path, "iPod_Control", "Device", "SysInfoExtended")
result["_sysinfo_extended_present"] = os.path.exists(sie_path)
if os.path.exists(sie_path):
try:
from .sysinfo import (
identity_from_sysinfo_extended,
parse_sysinfo_extended,
)
parsed = parse_sysinfo_extended(
Path(sie_path).read_bytes(),
source="sysinfo_extended",
)
result["_sysinfo_extended_keys"] = len(parsed.plist)
result["_sysinfo_extended_regex_fallback"] = parsed.used_regex_fallback
identity = identity_from_sysinfo_extended(parsed, "sysinfo_extended")
sources = identity.get("_sources", {})
for key, value in identity.items():
if key.startswith("_") or key in {
"model_raw",
"sysinfo_extended_raw_xml",
}:
continue
result[key] = value
if key in sources:
result["_sources"][key] = sources[key]
if identity.get("serial"):
logger.debug("SysInfoExtended: Apple serial: %s", identity["serial"])
if identity.get("firewire_guid"):
logger.debug("SysInfoExtended: FW GUID: %s", identity["firewire_guid"])
except Exception as exc:
logger.info("SysInfoExtended parse failed for %s: %s", ipod_path, exc)
# Try SysInfo
sysinfo_path = os.path.join(ipod_path, "iPod_Control", "Device", "SysInfo")
result["_sysinfo_present"] = os.path.exists(sysinfo_path)
if os.path.exists(sysinfo_path):
try:
from .sysinfo import identity_from_sysinfo, parse_sysinfo_text
sysinfo = parse_sysinfo_text(Path(sysinfo_path).read_text(errors="replace"))
result["_sysinfo_keys"] = len(sysinfo)
identity = identity_from_sysinfo(sysinfo, "sysinfo")
sources = identity.get("_sources", {})
for key, value in identity.items():
if key.startswith("_"):
continue
if key in result and result[key] not in (None, ""):
continue
result[key] = value
if key in sources:
result["_sources"].setdefault(key, sources[key])
if identity.get("model_number"):
from .lookup import get_model_info
mi = get_model_info(identity["model_number"])
if mi:
result.setdefault("model_family", mi[0])
result.setdefault("generation", mi[1])
result.setdefault("capacity", mi[2])
result.setdefault("color", mi[3])
if identity.get("serial"):
logger.debug("SysInfo: Apple serial: %s", identity["serial"])
if identity.get("firewire_guid"):
logger.debug("SysInfo: FW GUID: %s", identity["firewire_guid"])
except Exception as exc:
logger.info("SysInfo parse failed for %s: %s", ipod_path, exc)
return result if any(k != "_sources" for k in result) else None
def _extract_ipod_name(ipod_path: str) -> str:
"""
Lightweight extraction of the iPod's user-assigned name from the master
playlist title in the iTunesDB binary. Uses buffered positional reads
so only a few KB are transferred over USB — never reads the whole file.
For iTunesCDB files (Nano 5G+), the entire file must be read and
decompressed first — but these are typically small (< 200 KB compressed).
Returns the name string, or empty string if extraction fails.
"""
from .info import resolve_itdb_path
itdb_path = resolve_itdb_path(ipod_path)
if not itdb_path:
return ""
try:
# For iTunesCDB, we need to decompress the whole file first.
# Check if it's a CDB by reading the header.
with open(itdb_path, "rb") as f:
peek = f.read(16)
if len(peek) < 16 or peek[:4] != b"mhbd":
return ""
unk_0x0c = struct.unpack("<I", peek[12:16])[0]
if unk_0x0c == 2:
# Compressed CDB — read and decompress the full file, then
# use the parser's decompression to get the full data.
from iTunesDB_Parser.parser import decompress_itunescdb
with open(itdb_path, "rb") as f:
raw = f.read()
data = decompress_itunescdb(raw)
if len(data) < 24 or data[:4] != b"mhbd":
return ""
return _ipod_name_from_data(data)
else:
# Standard iTunesDB — use positional reads
with open(itdb_path, "rb") as f:
return _ipod_name_from_stream(f)
except (EOFError, OSError, struct.error) as e:
logger.debug("iPod name extraction failed: %s", e)
return ""
def _ipod_name_from_data(data: bytes) -> str:
"""Extract iPod name from a fully in-memory (decompressed) database."""
import io
return _ipod_name_from_stream(io.BytesIO(data))
def _ipod_name_from_stream(f) -> str:
"""Extract iPod name from an open file-like object (positional reads)."""
def _read(n: int) -> bytes:
buf = f.read(n)
if len(buf) < n:
raise EOFError("Unexpected end of iTunesDB")
return buf
try:
hdr = _read(24)
if hdr[:4] != b"mhbd":
return ""
mhbd_header_len = struct.unpack("<I", hdr[4:8])[0]
mhbd_children = struct.unpack("<I", hdr[20:24])[0]
def _name_from_mhsd(mhsd_pos: int, mhsd_hdr_len: int) -> str:
"""Try to read iPod name from the master playlist in an mhsd."""
mhlp_pos = mhsd_pos + mhsd_hdr_len
f.seek(mhlp_pos)
mhlp_hdr = _read(12)
if mhlp_hdr[:4] != b"mhlp":
return ""
mhlp_hdr_len = struct.unpack("<I", mhlp_hdr[4:8])[0]
pl_count = struct.unpack("<I", mhlp_hdr[8:12])[0]
if pl_count == 0:
return ""
# Walk playlists looking for the master (type=1)
mhyp_pos = mhlp_pos + mhlp_hdr_len
for _ in range(min(pl_count, 16)):
f.seek(mhyp_pos)
mhyp_hdr = _read(24)
if mhyp_hdr[:4] != b"mhyp":
return ""
mhyp_hdr_len = struct.unpack("<I", mhyp_hdr[4:8])[0]
mhyp_total = struct.unpack("<I", mhyp_hdr[8:12])[0]
mhod_count = struct.unpack("<I", mhyp_hdr[12:16])[0]
pl_type = mhyp_hdr[20] # 1 = master playlist
if pl_type == 1:
# Walk child MHODs to find type 1 (title)
mhod_pos = mhyp_pos + mhyp_hdr_len
for _ in range(min(mhod_count, 64)):
f.seek(mhod_pos)
mhod_hdr = _read(40)
if mhod_hdr[:4] != b"mhod":
break
mhod_total = struct.unpack("<I", mhod_hdr[8:12])[0]
mhod_type = struct.unpack("<I", mhod_hdr[12:16])[0]
if mhod_type == 1:
enc = struct.unpack("<I", mhod_hdr[24:28])[0]
slen = struct.unpack("<I", mhod_hdr[28:32])[0]
if slen > 1024:
break
sdata = _read(slen)
if enc == 2:
return sdata.decode("utf-8", errors="replace")
else:
return sdata.decode("utf-16-le", errors="replace")
mhod_pos += mhod_total
return "" # Had the master but couldn't read name
mhyp_pos += mhyp_total
return ""
# Walk mhsd children — try type 2 first (classic), fall back to
# type 3 (Nano 5G+ / newer iTunes omit type 2 entirely and put
# the master playlist in type 3 instead).
type3_pos: int | None = None
type3_hdr_len: int = 0
pos = mhbd_header_len
for _ in range(mhbd_children):
f.seek(pos)
mhsd_hdr = _read(16)
if mhsd_hdr[:4] != b"mhsd":
break
mhsd_hdr_len = struct.unpack("<I", mhsd_hdr[4:8])[0]
mhsd_total_len = struct.unpack("<I", mhsd_hdr[8:12])[0]
ds_type = struct.unpack("<I", mhsd_hdr[12:16])[0]
if ds_type == 2:
name = _name_from_mhsd(pos, mhsd_hdr_len)
if name:
return name
break # type 2 existed but couldn't extract — don't fallback
elif ds_type == 3 and type3_pos is None:
type3_pos = pos
type3_hdr_len = mhsd_hdr_len
pos += mhsd_total_len
# Type 2 wasn't found — try type 3
if type3_pos is not None:
name = _name_from_mhsd(type3_pos, type3_hdr_len)
if name:
return name
except (EOFError, OSError, struct.error) as e:
logger.debug("Could not extract iPod name: %s", e)
except Exception as e:
logger.debug("Could not extract iPod name (unexpected): %s", e)
return ""
def _identify_via_hashing_scheme(ipod_path: str) -> dict | None:
"""
Identify generation class from iTunesDB hashing_scheme field.
This is a fallback — it tells us the generation class but not the exact model.
"""
from .info import resolve_itdb_path
itdb_path = resolve_itdb_path(ipod_path)
if not itdb_path:
return None
try:
with open(itdb_path, "rb") as f:
header = f.read(0x72)
if len(header) < 0x32 or header[:4] != b"mhbd":
return None
scheme = struct.unpack("<H", header[0x30:0x32])[0]
result: dict = {"hashing_scheme": scheme}
# Map raw mhbd hashing_scheme to ChecksumType via canonical table
from .checksum import MHBD_SCHEME_TO_CHECKSUM, ChecksumType
cs_type = MHBD_SCHEME_TO_CHECKSUM.get(scheme)
if cs_type == ChecksumType.NONE or cs_type is None:
result["model_family"] = "iPod"
result["generation"] = "(pre-2007)"
elif cs_type == ChecksumType.HASH58:
result["model_family"] = "iPod"
result["generation"] = "(Classic or Nano 3G/4G)"
elif cs_type == ChecksumType.HASH72:
result["model_family"] = "iPod Nano"
result["generation"] = "(5th gen)"
elif cs_type == ChecksumType.HASHAB:
result["model_family"] = "iPod Nano"
result["generation"] = "(6th/7th gen)"
return result
except Exception:
return None
def _identify_via_serial_lookup(serial: str) -> dict | None:
"""Look up model from serial number's last 3 characters."""
from .lookup import lookup_by_serial
result = lookup_by_serial(serial)
if not result:
return None
model_num, info = result
return {
"model_number": model_num,
"model_family": info[0],
"generation": info[1],
"capacity": info[2],
"color": info[3],
}
def _estimate_capacity_from_disk_size(disk_gb: float) -> str:
"""Estimate marketed capacity from actual disk size.
.. deprecated:: Use :func:`ipod_device.info._estimate_capacity_from_disk_size` directly.
"""
from .info import _estimate_capacity_from_disk_size as _impl
return _impl(disk_gb)
def _try_vpd_identification(ipod: DeviceInfo) -> None:
"""Attempt full VPD-based identification for an incompletely resolved iPod.
Delegates to :func:`ipod_device.vpd_libusb.identify_via_vpd` on supported
non-Windows platforms.
SysInfo writing is NOT done here — the authority module handles it
after all identification is complete.
"""
if sys.platform == "win32":
logger.debug(
"Skipping inline VPD identification on Windows: mount=%s",
ipod.path,
)
return
try:
from .vpd_libusb import identify_via_vpd
except ImportError:
return
result = identify_via_vpd(
mount_path=ipod.path,
usb_pid=ipod.usb_pid,
firewire_guid=ipod.firewire_guid,
write_sysinfo_to_device=False,
)
if result is None:
return
# Apply resolved fields
if result["model_number"]:
ipod.model_number = result["model_number"]
ipod.model_family = result["model_family"]
ipod.generation = result["generation"]
ipod.capacity = result["capacity"]
ipod.color = result["color"]
ipod.identification_method = "usb_vpd"
ipod._field_sources["model_number"] = "vpd"
if not ipod.serial and result["serial"]:
ipod.serial = result["serial"]
ipod._field_sources["serial"] = "vpd"
if not ipod.firewire_guid and result["firewire_guid"]:
ipod.firewire_guid = result["firewire_guid"]
ipod._field_sources["firewire_guid"] = "vpd"
if not ipod.firmware and result["firmware"]:
ipod.firmware = result["firmware"]
ipod._field_sources["firmware"] = "vpd"
# Update mount path in case pyusb caused a remount to a different path
if result["mount_path"] and result["mount_path"] != ipod.path:
logger.info(" VPD: mount path changed %s%s", ipod.path, result["mount_path"])
ipod.path = result["mount_path"]
def _display_name_for_mount_path(mount_path: str) -> str:
"""Return the same style of display name used by broad volume discovery."""
if sys.platform == "win32":
drive, _tail = os.path.splitdrive(os.path.abspath(mount_path))
if drive:
return drive
normalized = os.path.normpath(mount_path)
return os.path.basename(normalized) or normalized
def identify_ipod_at_path(
ipod_path: str,
mount_name: str | None = None,
) -> DeviceInfo | None:
"""Identify one selected iPod root without scanning every mounted volume."""
if not ipod_path:
return None
expanded_path = os.path.expanduser(ipod_path)
drive, tail = os.path.splitdrive(expanded_path)
if sys.platform == "win32" and drive and tail in ("", "."):
mount_path = drive + "\\"
else:
mount_path = os.path.abspath(expanded_path)
virtual = _load_virtual_ipod_mount_if_present(mount_path)
if virtual is not None:
return virtual
if not _has_ipod_control(mount_path):
logger.info("Selected path is not an iPod root: %s", mount_path)
return None
display_name = mount_name or _display_name_for_mount_path(mount_path)
try:
return _identify_ipod_mount(mount_path, display_name)
finally:
_clear_macos_usb_cache()
def _identify_ipod_mount(mount_path: str, display_name: str) -> DeviceInfo:
"""Run the full identification pipeline for one already-discovered mount."""
from .info import enrich
virtual = _load_virtual_ipod_mount_if_present(mount_path)
if virtual is not None:
return virtual
ipod = DeviceInfo(path=mount_path, mount_name=display_name)
ipod.disk_size_gb, ipod.free_space_gb = _get_disk_info(mount_path)
logger.info(
"Identifying iPod: mount=%s display=%s disk=%.1fGB free=%.1fGB",
mount_path,
display_name,
ipod.disk_size_gb,
ipod.free_space_gb,
)
# Phase 1: Hardware probing
hw = _probe_hardware(mount_path, display_name)
# Phase 2: Filesystem probing
fs = _probe_filesystem(mount_path)
# Phase 3: Model resolution (per-field priority merge)
resolved = _resolve_model(hw, fs, ipod.disk_size_gb)
# Apply resolved fields to the DeviceInfo
ipod.model_number = resolved.get("model_number", "")
ipod.model_family = resolved.get("model_family", "iPod")
ipod.generation = resolved.get("generation", "")
ipod.capacity = resolved.get("capacity", "")
ipod.color = resolved.get("color", "")
ipod.firewire_guid = resolved.get("firewire_guid", "")
ipod.serial = resolved.get("serial", "")
ipod.firmware = resolved.get("firmware", "")
ipod.usb_pid = resolved.get("usb_pid", 0)
ipod.hashing_scheme = resolved.get("hashing_scheme", -1)
ipod.identification_method = resolved.get("identification_method", "filesystem")
# `DeviceInfo.raw_identity_evidence` expects lists of evidence dicts;
# wrap the hw/fs dicts in single-item lists to satisfy the type.
ipod.raw_identity_evidence = {
"hardware": [hw] if hw is not None else [],
"filesystem": [fs] if fs is not None else [],
}
ipod.identity_conflicts = list(resolved.get("_conflicts", []))
for field in (
"family_id",
"updater_family_id",
"product_type",
"usb_vid",
"usb_serial",
"usbstor_instance_id",
"usb_parent_instance_id",
"usb_grandparent_instance_id",
"scsi_vendor",
"scsi_product",
"scsi_revision",
"connected_bus",
"volume_format",
"db_version",
"shadow_db_version",
"uses_sqlite_db",
"supports_sparse_artwork",
"max_tracks",
"max_file_size_gb",
"max_transfer_speed",
"podcasts_supported",
"voice_memos_supported",
"audio_codecs",
"power_information",
"apple_drm_version",
"artwork_formats",
"photo_formats",
"chapter_image_formats",
):
value = resolved.get(field)
if value not in (None, "", b"", {}, []):
setattr(ipod, field, value)
# Apply per-field provenance from the resolution phase
resolved_sources = resolved.get("_sources", {})
if resolved_sources:
ipod._field_sources.update(resolved_sources)
# Phase 4: Inline VPD for incomplete identification (non-Windows only)
if sys.platform != "win32" and not ipod.model_number and ipod.usb_pid:
_try_vpd_identification(ipod)
# Extract user-assigned iPod name from master playlist
ipod.ipod_name = _extract_ipod_name(mount_path)
# Estimate capacity from disk size if still unknown
if not ipod.capacity and ipod.disk_size_gb > 0:
ipod.capacity = _estimate_capacity_from_disk_size(ipod.disk_size_gb)
if ipod.capacity:
ipod._field_sources["capacity"] = "disk_size"
# Phase 5: Enrich (fills checksum, artwork, HashInfo, etc.)
enrich(ipod)
ipod_data = ipod.__dict__
logger.info(
"iPod identified: mount=%s display=%s identity=[%s] caps=[%s] "
"method=%s checksum=%s hash_scheme=%s sources=[%s] conflicts=[%s]",
ipod.path,
ipod.display_name,
format_fields(ipod_data, IDENTITY_FIELDS),
format_fields(ipod_data, CAPABILITY_FIELDS, include_false=True),
ipod.identification_method,
ipod.checksum_type,
ipod.hashing_scheme,
format_sources(ipod._field_sources, SOURCE_FIELDS),
format_conflicts(ipod.identity_conflicts),
)
return ipod
def _load_virtual_ipod_mount_if_present(mount_path: str) -> DeviceInfo | None:
"""Load root-level iPodInfo.json metadata when this is a virtual iPod."""
try:
from .virtual import (
ensure_virtual_itunes_database,
has_virtual_ipod_info,
load_virtual_ipod_info,
)
if not has_virtual_ipod_info(mount_path):
return None
ensure_virtual_itunes_database(mount_path)
return load_virtual_ipod_info(mount_path)
except Exception as exc:
logger.warning("Virtual iPod metadata could not be loaded: %s", exc)
return None
def scan_for_ipods() -> list[DeviceInfo]:
"""
Scan all mounted volumes for connected iPods.
Uses a unified four-phase pipeline, then calls ``enrich()`` so each
returned :class:`DeviceInfo` is fully populated (checksum type, artwork
formats, HashInfo, disk stats, etc.).
**Phase 1 — Hardware probing** (platform-specific):
Windows: Direct IOCTL + device tree walk, with silent WMI fallback.
macOS: system_profiler SPUSBDataType for USB device identification.
Linux: sysfs traversal from block device to USB device.
**Phase 2 — Filesystem probing** (cross-platform file reads):
SysInfo / SysInfoExtended + iTunesDB header.
**Phase 3 — Model resolution** (per-field priority merge):
SysInfo ModelNumStr > serial last-3 > USB PID > hashing_scheme.
**Phase 4 — Inline VPD** (macOS only, for incomplete identification):
If model_number is still unknown after Phase 3, query the iPod's
firmware via IOKit SCSI VPD to get the Apple serial, then resolve
via serial-last-3 lookup. Writes SysInfo so this only runs once.
**Phase 5 — Enrich** (fills derived fields: checksum, artwork, etc.)
Returns a list of fully-enriched DeviceInfo objects.
"""
ipods: list[DeviceInfo] = []
logger.info("iPod scan started")
candidates: list[tuple[str, str]] = []
try:
candidates = _find_ipod_volumes()
for mount_path, display_name in candidates:
ipods.append(_identify_ipod_mount(mount_path, display_name))
finally:
# Clear the macOS ioreg caches so they're fresh on the next rescan.
_clear_macos_usb_cache()
mounts = ", ".join(display for _path, display in candidates) or "none"
logger.info("iPod scan finished: count=%d mounts=%s", len(ipods), mounts)
return ipods