129 lines
5.5 KiB
Python
129 lines
5.5 KiB
Python
"""Find SD cards (removable disks) on Windows and macOS.
|
|
|
|
Safety first: only USB/SD/MMC-attached disks that are not the system or
|
|
boot disk and not larger than MAX_CARD_BYTES are ever offered. Parsers are
|
|
pure functions over the OS tool output so they can be tested without
|
|
hardware.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import plistlib
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
|
|
MAX_CARD_BYTES = 256 * 1024**3 # larger "removable" disks are probably not SD cards
|
|
CARD_BUSES = {"USB", "SD", "MMC"}
|
|
|
|
|
|
@dataclass
|
|
class Volume:
|
|
mount: str # "E:\\" on Windows, "/Volumes/KOTA" on macOS ("" if not mounted)
|
|
filesystem: str # "FAT32", "exFAT", "MS-DOS FAT32", ...
|
|
label: str
|
|
|
|
|
|
@dataclass
|
|
class Disk:
|
|
id: str # Windows disk number / macOS "/dev/disk4"
|
|
name: str
|
|
size: int
|
|
bus: str
|
|
volumes: list[Volume] = field(default_factory=list)
|
|
has_media: bool = True
|
|
|
|
@property
|
|
def size_text(self) -> str:
|
|
return f"{self.size / 1e9:.1f} GB" if self.size else "no card"
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
vols = ", ".join(f"{v.mount or '-'} {v.filesystem} '{v.label}'" for v in self.volumes) or "no volume"
|
|
return f"Disk {self.id}: {self.name} - {self.size_text} ({self.bus}) - {vols}"
|
|
|
|
|
|
# ------------------------------------------------------------------ Windows
|
|
|
|
WIN_LIST_PS = r"""
|
|
$ErrorActionPreference = 'SilentlyContinue'
|
|
# Filter by bus BEFORE touching partitions: querying volumes of every
|
|
# internal disk made this take >30 s on a machine with several HDDs.
|
|
$out = @(Get-Disk | Where-Object { -not $_.IsSystem -and -not $_.IsBoot -and
|
|
@('USB','SD','MMC') -contains [string]$_.BusType } | ForEach-Object {
|
|
$d = $_
|
|
$vols = @(Get-Partition -DiskNumber $d.Number | ForEach-Object {
|
|
$v = $_ | Get-Volume
|
|
[pscustomobject]@{ DriveLetter = [string]$_.DriveLetter; FileSystem = [string]$v.FileSystem; Label = [string]$v.FileSystemLabel }
|
|
})
|
|
[pscustomobject]@{ Number = $d.Number; FriendlyName = $d.FriendlyName; Size = [int64]$d.Size;
|
|
BusType = [string]$d.BusType; Volumes = $vols }
|
|
})
|
|
ConvertTo-Json -InputObject $out -Depth 4 -Compress
|
|
"""
|
|
|
|
|
|
def parse_windows(json_text: str) -> list[Disk]:
|
|
data = json.loads(json_text or "[]")
|
|
if isinstance(data, dict): # ConvertTo-Json quirk for one element
|
|
data = [data]
|
|
disks = []
|
|
for d in data:
|
|
if str(d.get("BusType")) not in CARD_BUSES:
|
|
continue
|
|
vols = d.get("Volumes") or []
|
|
if isinstance(vols, dict):
|
|
vols = [vols]
|
|
volumes = [Volume(f"{v['DriveLetter']}:\\" if v.get("DriveLetter") and v["DriveLetter"] not in ("\x00", "") else "",
|
|
v.get("FileSystem") or "", v.get("Label") or "") for v in vols]
|
|
size = int(d.get("Size") or 0)
|
|
disks.append(Disk(str(d["Number"]), d.get("FriendlyName") or "Disk", size, str(d["BusType"]),
|
|
[v for v in volumes if v.mount or v.filesystem], has_media=size > 0))
|
|
return disks
|
|
|
|
|
|
# -------------------------------------------------------------------- macOS
|
|
|
|
def parse_macos(list_plist: bytes, infos: dict[str, bytes]) -> list[Disk]:
|
|
"""`diskutil list -plist physical` + `diskutil info -plist <disk>` for each."""
|
|
listing = plistlib.loads(list_plist)
|
|
disks = []
|
|
for entry in listing.get("AllDisksAndPartitions", []):
|
|
ident = entry.get("DeviceIdentifier", "")
|
|
info = plistlib.loads(infos[ident]) if ident in infos else {}
|
|
protocol = str(info.get("BusProtocol", ""))
|
|
removable = bool(info.get("RemovableMedia") or info.get("Removable") or info.get("Ejectable"))
|
|
internal = bool(info.get("Internal", True))
|
|
is_root = any(p.get("MountPoint") == "/" for p in entry.get("Partitions", []) + entry.get("APFSVolumes", []))
|
|
if ident == "disk0" or is_root or not (removable or not internal or protocol in ("USB", "Secure Digital", "SD")):
|
|
continue
|
|
vols = [Volume(p.get("MountPoint", ""), p.get("Content", ""), p.get("VolumeName", ""))
|
|
for p in entry.get("Partitions", [])]
|
|
size = int(entry.get("Size") or info.get("TotalSize") or 0)
|
|
bus = "SD" if "Secure Digital" in protocol or protocol == "SD" else (protocol or "USB")
|
|
disks.append(Disk(f"/dev/{ident}", info.get("MediaName", "Disk"), size, bus, vols, has_media=size > 0))
|
|
return disks
|
|
|
|
|
|
# ------------------------------------------------------------------ public
|
|
|
|
def list_cards() -> tuple[list[Disk], list[Disk]]:
|
|
"""(cards that may be formatted, readers/disks shown but not offered)."""
|
|
if sys.platform == "win32":
|
|
out = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", WIN_LIST_PS],
|
|
capture_output=True, text=True, timeout=90,
|
|
creationflags=subprocess.CREATE_NO_WINDOW).stdout
|
|
disks = parse_windows(out)
|
|
elif sys.platform == "darwin":
|
|
lst = subprocess.run(["diskutil", "list", "-plist", "physical"], capture_output=True, timeout=30).stdout
|
|
infos = {}
|
|
for e in plistlib.loads(lst).get("AllDisksAndPartitions", []):
|
|
ident = e.get("DeviceIdentifier", "")
|
|
infos[ident] = subprocess.run(["diskutil", "info", "-plist", ident], capture_output=True, timeout=30).stdout
|
|
disks = parse_macos(lst, infos)
|
|
else:
|
|
return [], []
|
|
ok = [d for d in disks if d.has_media and 0 < d.size <= MAX_CARD_BYTES]
|
|
return ok, [d for d in disks if d not in ok]
|