Files
kkallas 22bd853ecf Technician tool v0.6
Imported from the Google Drive folder 004-technician_tool/v0.6.
2026-09-28 17:31:03 +02:00

129 lines
5.4 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":
# Driver-level listing (milliseconds, 1.5 s worst case) instead of
# Get-Disk/WMI, which stalled 5-21 s on an empty card-reader slot.
from .win_disks import list_disks
disks = list_disks(CARD_BUSES)
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]