Technician tool v0.6
Imported from the Google Drive folder 004-technician_tool/v0.6.
This commit is contained in:
1 parent
f5b32e1554
commit
22bd853ecf
30 files changed
+1521
-568
No files matched your search
@@ -111,10 +111,10 @@ def parse_macos(list_plist: bytes, infos: dict[str, bytes]) -> list[Disk]:
|
||||
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)
|
||||
# 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 = {}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""FAT32 + MBR layout for an SD card, as raw byte writes (no OS formatter).
|
||||
|
||||
Follows the Microsoft FAT specification (fatgen103): BPB at sector 0,
|
||||
FSInfo at 1, backup boot sector at 6, two FATs, root directory in cluster 2
|
||||
holding the volume label. Cluster sizes follow Windows' defaults. The
|
||||
partition starts at 1 MiB (SD erase-block friendly), MBR type 0x0C
|
||||
(FAT32 LBA), which is what the firmware's FatFs expects.
|
||||
|
||||
Pure functions: the result is a list of (offset, bytes) writes plus zero
|
||||
ranges relative to the start of the DISK, so it can be tested on a buffer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
PART_START = 1024 * 1024
|
||||
FAT32_MAX_BYTES = 32 * 1024**3 # Windows convention, and all the box needs
|
||||
MIN_CLUSTERS, MAX_CLUSTERS = 65525, 0x0FFFFFF5
|
||||
RESERVED = 32
|
||||
NUM_FATS = 2
|
||||
MEDIA = 0xF8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fat32Layout:
|
||||
bytes_per_sector: int
|
||||
part_start: int # bytes from disk start
|
||||
part_sectors: int
|
||||
sectors_per_cluster: int
|
||||
fat_sectors: int
|
||||
clusters: int
|
||||
|
||||
@property
|
||||
def data_start(self) -> int: # sectors from partition start
|
||||
return RESERVED + NUM_FATS * self.fat_sectors
|
||||
|
||||
|
||||
def _spc(sectors: int, bps: int) -> int:
|
||||
size = sectors * bps
|
||||
for limit, cluster in ((260 * 1024**2, 512), (8 * 1024**3, 4096), (16 * 1024**3, 8192),
|
||||
(32 * 1024**3, 16384)):
|
||||
if size <= limit:
|
||||
return max(1, cluster // bps)
|
||||
return max(1, 32768 // bps)
|
||||
|
||||
|
||||
def plan(disk_bytes: int, bps: int = 512) -> Fat32Layout:
|
||||
part_bytes = min(disk_bytes - PART_START, FAT32_MAX_BYTES)
|
||||
part_bytes -= part_bytes % (1024 * 1024) # whole MiB
|
||||
sectors = part_bytes // bps
|
||||
if sectors <= 0:
|
||||
raise ValueError("card is too small")
|
||||
spc = _spc(sectors, bps)
|
||||
fat = 1
|
||||
for _ in range(20): # converge FAT size <-> cluster count
|
||||
clusters = (sectors - RESERVED - NUM_FATS * fat) // spc
|
||||
need = math.ceil((clusters + 2) * 4 / bps)
|
||||
if need == fat:
|
||||
break
|
||||
fat = need
|
||||
clusters = (sectors - RESERVED - NUM_FATS * fat) // spc
|
||||
if not MIN_CLUSTERS <= clusters < MAX_CLUSTERS:
|
||||
raise ValueError(f"card size gives {clusters} clusters - not valid for FAT32")
|
||||
return Fat32Layout(bps, PART_START, sectors, spc, fat, clusters)
|
||||
|
||||
|
||||
def _boot_sector(lay: Fat32Layout, label: bytes, vol_id: int) -> bytes:
|
||||
bps = lay.bytes_per_sector
|
||||
b = bytearray(bps)
|
||||
b[0:3] = b"\xEB\x58\x90"
|
||||
b[3:11] = b"MSWIN4.1"
|
||||
struct.pack_into("<HBHBHHBHHHII", b, 11, bps, lay.sectors_per_cluster, RESERVED, NUM_FATS, 0, 0,
|
||||
MEDIA, 0, 63, 255, lay.part_start // bps, lay.part_sectors)
|
||||
struct.pack_into("<IHHIHH", b, 36, lay.fat_sectors, 0, 0, 2, 1, 6)
|
||||
struct.pack_into("<BBBI", b, 64, 0x80, 0, 0x29, vol_id)
|
||||
b[71:82] = label
|
||||
b[82:90] = b"FAT32 "
|
||||
b[90:93] = b"\xF4\xEB\xFD" # not bootable: hlt loop
|
||||
b[510:512] = b"\x55\xAA"
|
||||
return bytes(b)
|
||||
|
||||
|
||||
def _fsinfo(lay: Fat32Layout) -> bytes:
|
||||
b = bytearray(lay.bytes_per_sector)
|
||||
struct.pack_into("<I", b, 0, 0x41615252)
|
||||
struct.pack_into("<III", b, 484, 0x61417272, lay.clusters - 1, 3) # root uses cluster 2
|
||||
struct.pack_into("<I", b, 508, 0xAA550000)
|
||||
return bytes(b)
|
||||
|
||||
|
||||
def mbr(lay: Fat32Layout, signature: int) -> bytes:
|
||||
b = bytearray(512)
|
||||
struct.pack_into("<I", b, 0x1B8, signature)
|
||||
entry = struct.pack("<B3sB3sII", 0x00, b"\xFE\xFF\xFF", 0x0C, b"\xFE\xFF\xFF",
|
||||
lay.part_start // lay.bytes_per_sector, lay.part_sectors)
|
||||
b[0x1BE:0x1BE + 16] = entry
|
||||
b[510:512] = b"\x55\xAA"
|
||||
return bytes(b[:512]) + bytes(lay.bytes_per_sector - 512)
|
||||
|
||||
|
||||
def volume_label(text: str) -> bytes:
|
||||
label = text.upper().encode("ascii", "replace")[:11]
|
||||
return label.ljust(11, b" ")
|
||||
|
||||
|
||||
def build(lay: Fat32Layout, label: str, vol_id: int, disk_signature: int,
|
||||
disk_bytes: int) -> tuple[list[tuple[int, int]], list[tuple[int, bytes]]]:
|
||||
"""(zero ranges, writes) in disk byte offsets; apply zero ranges first."""
|
||||
bps, p = lay.bytes_per_sector, lay.part_start
|
||||
lab = volume_label(label)
|
||||
fat_bytes = lay.fat_sectors * bps
|
||||
fat1 = p + RESERVED * bps
|
||||
fat2 = fat1 + fat_bytes
|
||||
root = p + lay.data_start * bps
|
||||
cluster = lay.sectors_per_cluster * bps
|
||||
zeros = [
|
||||
(0, PART_START), # old MBR / GPT header and gap
|
||||
(disk_bytes - PART_START, PART_START), # backup GPT at the end of the disk
|
||||
(p, RESERVED * bps), # reserved region
|
||||
(fat1, NUM_FATS * fat_bytes), # both FATs
|
||||
(root, cluster), # root directory cluster
|
||||
]
|
||||
boot, info = _boot_sector(lay, lab, vol_id), _fsinfo(lay)
|
||||
first_fat = struct.pack("<III", 0x0FFFFF00 | MEDIA, 0x0FFFFFFF, 0x0FFFFFFF)
|
||||
label_entry = lab + bytes([0x08]) + bytes(20) # 32-byte volume-label directory entry
|
||||
writes = [
|
||||
(p, boot), (p + bps, info), (p + 6 * bps, boot), (p + 7 * bps, info),
|
||||
(fat1, first_fat), (fat2, first_fat),
|
||||
(root, label_entry),
|
||||
(0, mbr(lay, disk_signature)), # last: the partition appears only now
|
||||
]
|
||||
return zeros, writes
|
||||
+49
-62
@@ -3,11 +3,12 @@
|
||||
Card format required by the firmware: MBR partition table, FAT32 (its FAT
|
||||
driver has no exFAT and no long file names), ENV.TXT in the root.
|
||||
|
||||
Windows: formatting needs administrator rights, so a generated PowerShell
|
||||
script runs elevated (UAC prompt). The script re-checks the disk (number,
|
||||
size, bus, not system/boot) right before erasing it, so a card swapped
|
||||
after the technician confirmed is not touched. Windows cannot format FAT32
|
||||
above 32 GB, so larger cards get a 32 GB partition (plenty for the box).
|
||||
Windows: formatting needs administrator rights, so kota_tech.sd.win_format
|
||||
runs elevated (UAC prompt). It re-checks the disk (number, size, bus, not the
|
||||
Windows drive) right before erasing it, so a card swapped after the
|
||||
technician confirmed is not touched, and it writes MBR + FAT32 itself
|
||||
(no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
|
||||
get a 32 GB FAT32 partition (Windows convention; plenty for the box).
|
||||
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
|
||||
"""
|
||||
|
||||
@@ -23,11 +24,10 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .disks import CARD_BUSES, Disk
|
||||
from .disks import Disk
|
||||
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
|
||||
|
||||
VOLUME_LABEL = "KOTA"
|
||||
FAT32_MAX = 32 * 1024**3
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -49,31 +49,6 @@ class CardReport:
|
||||
|
||||
# ----------------------------------------------------------------- Windows
|
||||
|
||||
def windows_format_script(disk: Disk, result_file: Path) -> str:
|
||||
buses = ",".join(f"'{b}'" for b in sorted(CARD_BUSES))
|
||||
return f"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$result = @{{ ok = $false; message = ''; drive = '' }}
|
||||
try {{
|
||||
$n = {int(disk.id)}
|
||||
$d = Get-Disk -Number $n
|
||||
if ($d.IsSystem -or $d.IsBoot) {{ throw 'Refusing: this is a system/boot disk.' }}
|
||||
if (@({buses}) -notcontains [string]$d.BusType) {{ throw "Refusing: disk bus is $($d.BusType), not an SD/USB card." }}
|
||||
if ([int64]$d.Size -ne {int(disk.size)}) {{ throw 'Refusing: the disk changed since it was selected (card swapped?).' }}
|
||||
if ($d.PartitionStyle -ne 'RAW') {{ Clear-Disk -Number $n -RemoveData -RemoveOEM -Confirm:$false }}
|
||||
Initialize-Disk -Number $n -PartitionStyle MBR -ErrorAction SilentlyContinue
|
||||
$size = [Math]::Min([int64]$d.Size - 2MB, [int64]{FAT32_MAX})
|
||||
$p = New-Partition -DiskNumber $n -Size $size -MbrType FAT32 -AssignDriveLetter
|
||||
Format-Volume -Partition $p -FileSystem FAT32 -NewFileSystemLabel '{VOLUME_LABEL}' -Confirm:$false -Force | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
$letter = (Get-Partition -DiskNumber $n -PartitionNumber $p.PartitionNumber).DriveLetter
|
||||
if (-not $letter) {{ throw 'Formatted, but Windows gave the card no drive letter.' }}
|
||||
$result.ok = $true; $result.drive = "$($letter):\\"; $result.message = 'Formatted FAT32 (MBR).'
|
||||
}} catch {{ $result.message = $_.Exception.Message }}
|
||||
$result | ConvertTo-Json | Out-File -FilePath '{result_file}' -Encoding utf8
|
||||
"""
|
||||
|
||||
|
||||
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
|
||||
|
||||
|
||||
@@ -86,42 +61,54 @@ def _workdir() -> Path:
|
||||
return work
|
||||
|
||||
|
||||
def _query_windows_card(number: int) -> tuple[str, str, str]:
|
||||
"""(drive, filesystem, label) of the card's first partition - no admin rights needed."""
|
||||
ps = (f"$p = Get-Partition -DiskNumber {number} -ErrorAction SilentlyContinue | Select-Object -First 1; "
|
||||
"if ($p) { $v = $p | Get-Volume -ErrorAction SilentlyContinue; "
|
||||
"\"$($p.DriveLetter)|$($v.FileSystem)|$($v.FileSystemLabel)\" }")
|
||||
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True,
|
||||
timeout=90, creationflags=subprocess.CREATE_NO_WINDOW).stdout.strip()
|
||||
drive, fs, label = (out.split("|") + ["", "", ""])[:3]
|
||||
return (f"{drive}:\\" if drive.strip() else ""), fs, label
|
||||
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
|
||||
"""(program, parameters, working dir) for the elevated helper."""
|
||||
root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/
|
||||
params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
|
||||
f'--label {VOLUME_LABEL} --result "{result}"'
|
||||
return sys.executable, params, str(root)
|
||||
|
||||
|
||||
def _run_elevated(program: str, params: str, cwd: str, timeout_s: int = 300) -> int | None:
|
||||
"""ShellExecuteEx 'runas' + wait. None if the technician refused the UAC prompt."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class SHELLEXECUTEINFOW(ctypes.Structure):
|
||||
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
|
||||
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
|
||||
("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
|
||||
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
|
||||
("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
|
||||
|
||||
SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
|
||||
info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
|
||||
lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
|
||||
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
if not shell32.ShellExecuteExW(ctypes.byref(info)):
|
||||
return None # ERROR_CANCELLED (1223) or failure
|
||||
k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
|
||||
code = wintypes.DWORD()
|
||||
k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
|
||||
k32.CloseHandle(info.hProcess)
|
||||
return code.value
|
||||
|
||||
|
||||
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
|
||||
work = _workdir()
|
||||
script, result = work / "format_card.ps1", work / "result.json"
|
||||
script.write_text(windows_format_script(disk, result), encoding="utf-8-sig")
|
||||
result = _workdir() / "result.json"
|
||||
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
|
||||
launcher = ("$p = Start-Process powershell -Verb RunAs -Wait -PassThru -WindowStyle Hidden "
|
||||
f"-ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"{script}\"'; exit $p.ExitCode")
|
||||
proc = subprocess.run(["powershell", "-NoProfile", "-Command", launcher], capture_output=True, text=True,
|
||||
timeout=600, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
if not result.exists():
|
||||
err = (proc.stderr or "").strip().splitlines()
|
||||
cancelled = any("canceled" in e.lower() or "cancelled" in e.lower() for e in err)
|
||||
return Outcome(False, "Administrator permission was refused - the card was not formatted." if cancelled
|
||||
else "Formatting did not run: " + (err[-1] if err else "unknown error"), log=log)
|
||||
t = time.perf_counter()
|
||||
code = _run_elevated(*win_format_command(disk, result))
|
||||
if code is None:
|
||||
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
|
||||
try:
|
||||
res = json.loads(result.read_text(encoding="utf-8-sig"))
|
||||
res = json.loads(result.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
# The script ran but its report is unreadable: judge by the card itself.
|
||||
log.append(f"Could not read the format result ({exc.__class__.__name__}); checking the card directly.")
|
||||
drive, fs, label = _query_windows_card(int(disk.id))
|
||||
ok = bool(drive) and fs.upper() == "FAT32" and label == VOLUME_LABEL
|
||||
res = {"ok": ok, "drive": drive,
|
||||
"message": "Formatted FAT32 (MBR)." if ok else f"Card state after formatting: {fs or 'no file system'} "
|
||||
f"'{label}' - formatting probably failed."}
|
||||
log.append(res.get("message", ""))
|
||||
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
|
||||
"Check the card with 'Check card'.", log=log)
|
||||
steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items())
|
||||
log.append(f"{res.get('message', '')} ({time.perf_counter() - t:.1f} s incl. permission prompt; {steps})")
|
||||
return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Fast Windows card discovery straight from the storage drivers (ctypes).
|
||||
|
||||
Why not Get-Disk / WMI: every fresh PowerShell/WMI query enumerates ALL
|
||||
disks, which wakes sleeping hard drives - measured 1.6 to 21 s per listing
|
||||
on a PC with two SATA HDDs, freezing the UI each time. Here each physical
|
||||
drive is opened with access 0 (no admin needed, no I/O to the medium) and
|
||||
only its cached device descriptor is read; size and volumes are queried only
|
||||
for USB/SD/MMC disks. A full listing takes milliseconds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import ctypes
|
||||
import string
|
||||
from ctypes import wintypes
|
||||
|
||||
from .disks import Disk, Volume
|
||||
|
||||
IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400
|
||||
IOCTL_STORAGE_GET_DEVICE_NUMBER = 0x002D1080
|
||||
IOCTL_DISK_GET_DRIVE_GEOMETRY_EX = 0x000700A0
|
||||
BUS_TYPES = {1: "SCSI", 2: "ATAPI", 3: "ATA", 4: "1394", 7: "USB", 8: "RAID", 10: "SAS", 11: "SATA",
|
||||
12: "SD", 13: "MMC", 14: "Virtual", 15: "FileBackedVirtual", 17: "NVMe", 18: "SCM", 19: "UFS"}
|
||||
SHARE_RW, OPEN_EXISTING = 0x3, 3
|
||||
DRIVE_REMOVABLE, DRIVE_FIXED = 2, 3
|
||||
MAX_DRIVES = 32
|
||||
|
||||
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
_k32.CreateFileW.restype = wintypes.HANDLE
|
||||
_k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
|
||||
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
||||
_k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
|
||||
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||||
_k32.GetDriveTypeW.argtypes = [wintypes.LPCWSTR]
|
||||
_k32.GetVolumeInformationW.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD),
|
||||
ctypes.POINTER(wintypes.DWORD), wintypes.LPWSTR, wintypes.DWORD]
|
||||
_INVALID = wintypes.HANDLE(-1).value
|
||||
|
||||
|
||||
class _Query(ctypes.Structure): # STORAGE_PROPERTY_QUERY (StorageDeviceProperty, standard)
|
||||
_fields_ = [("PropertyId", wintypes.DWORD), ("QueryType", wintypes.DWORD), ("Extra", ctypes.c_ubyte * 4)]
|
||||
|
||||
|
||||
class _DevNumber(ctypes.Structure): # STORAGE_DEVICE_NUMBER
|
||||
_fields_ = [("DeviceType", wintypes.DWORD), ("DeviceNumber", wintypes.DWORD),
|
||||
("PartitionNumber", wintypes.DWORD)]
|
||||
|
||||
|
||||
def _open(path: str):
|
||||
h = _k32.CreateFileW(path, 0, SHARE_RW, None, OPEN_EXISTING, 0, None)
|
||||
return None if h in (None, _INVALID) else h
|
||||
|
||||
|
||||
def _ioctl(h, code: int, inbuf=None, insize: int = 0, outsize: int = 1024) -> bytes | None:
|
||||
out = ctypes.create_string_buffer(outsize)
|
||||
got = wintypes.DWORD()
|
||||
ok = _k32.DeviceIoControl(h, code, inbuf, insize, out, outsize, ctypes.byref(got), None)
|
||||
return out.raw[:got.value] if ok else None
|
||||
|
||||
|
||||
def _descriptor(h) -> tuple[str, str] | None:
|
||||
"""(bus, "vendor product") from STORAGE_DEVICE_DESCRIPTOR - served from the driver's cache."""
|
||||
q = _Query(0, 0)
|
||||
raw = _ioctl(h, IOCTL_STORAGE_QUERY_PROPERTY, ctypes.byref(q), ctypes.sizeof(q))
|
||||
if not raw or len(raw) < 32:
|
||||
return None
|
||||
vendor_off, product_off = int.from_bytes(raw[12:16], "little"), int.from_bytes(raw[16:20], "little")
|
||||
bus = int.from_bytes(raw[28:32], "little")
|
||||
|
||||
def text(off: int) -> str:
|
||||
if not off or off >= len(raw):
|
||||
return ""
|
||||
return raw[off:].split(b"\0", 1)[0].decode("ascii", "replace").strip()
|
||||
|
||||
return BUS_TYPES.get(bus, f"bus{bus}"), " ".join(t for t in (text(vendor_off), text(product_off)) if t) or "Disk"
|
||||
|
||||
|
||||
def _size(h) -> int:
|
||||
raw = _ioctl(h, IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, outsize=256) # fails when no card is inserted
|
||||
return int.from_bytes(raw[24:32], "little") if raw and len(raw) >= 32 else 0
|
||||
|
||||
|
||||
def _volumes_by_disk() -> dict[int, list[Volume]]:
|
||||
found: dict[int, list[Volume]] = {}
|
||||
mask = _k32.GetLogicalDrives()
|
||||
for i, letter in enumerate(string.ascii_uppercase):
|
||||
if not mask & (1 << i) or _k32.GetDriveTypeW(f"{letter}:\\") not in (DRIVE_REMOVABLE, DRIVE_FIXED):
|
||||
continue
|
||||
h = _open(f"\\\\.\\{letter}:")
|
||||
if h is None:
|
||||
continue
|
||||
try:
|
||||
raw = _ioctl(h, IOCTL_STORAGE_GET_DEVICE_NUMBER, outsize=ctypes.sizeof(_DevNumber))
|
||||
finally:
|
||||
_k32.CloseHandle(h)
|
||||
if raw and len(raw) >= 8:
|
||||
num = int.from_bytes(raw[4:8], "little")
|
||||
found.setdefault(num, []).append(Volume(f"{letter}:\\", "", ""))
|
||||
return found
|
||||
|
||||
|
||||
def _fill_volume_info(v: Volume) -> None:
|
||||
name, fs = ctypes.create_unicode_buffer(64), ctypes.create_unicode_buffer(32)
|
||||
serial, maxlen, flags = wintypes.DWORD(), wintypes.DWORD(), wintypes.DWORD()
|
||||
if _k32.GetVolumeInformationW(v.mount, name, 64, ctypes.byref(serial), ctypes.byref(maxlen),
|
||||
ctypes.byref(flags), fs, 32):
|
||||
v.filesystem, v.label = fs.value, name.value
|
||||
|
||||
|
||||
MEDIA_TIMEOUT_S = 1.5
|
||||
# Slot queries still running from an earlier listing (an empty card-reader
|
||||
# slot can block ~13 s on its first media query). Never start a second one.
|
||||
_pending: dict[int, concurrent.futures.Future] = {}
|
||||
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="sd-media")
|
||||
|
||||
|
||||
def _media_size(n: int) -> int:
|
||||
h = _open(f"\\\\.\\PhysicalDrive{n}")
|
||||
if h is None:
|
||||
return 0
|
||||
try:
|
||||
return _size(h)
|
||||
finally:
|
||||
_k32.CloseHandle(h)
|
||||
|
||||
|
||||
def list_disks(buses: set[str]) -> list[Disk]:
|
||||
"""Disks on the given buses (e.g. {"USB", "SD", "MMC"}), with size and volumes.
|
||||
|
||||
Media queries run in parallel with a short timeout: a slot that does not
|
||||
answer in time is reported as "no card" instead of freezing the caller.
|
||||
"""
|
||||
candidates: list[tuple[int, tuple[str, str]]] = []
|
||||
for n in range(MAX_DRIVES):
|
||||
h = _open(f"\\\\.\\PhysicalDrive{n}")
|
||||
if h is None:
|
||||
continue
|
||||
try:
|
||||
desc = _descriptor(h)
|
||||
finally:
|
||||
_k32.CloseHandle(h)
|
||||
if desc is not None and desc[0] in buses: # internal disks: only the cached descriptor is read
|
||||
candidates.append((n, desc))
|
||||
for n, _ in candidates:
|
||||
if n not in _pending or _pending[n].done():
|
||||
_pending[n] = _pool.submit(_media_size, n)
|
||||
concurrent.futures.wait([_pending[n] for n, _ in candidates], timeout=MEDIA_TIMEOUT_S)
|
||||
volumes: dict[int, list[Volume]] | None = None
|
||||
disks = []
|
||||
for n, (bus, name) in candidates:
|
||||
fut = _pending[n]
|
||||
size = fut.result() if fut.done() and fut.exception() is None else 0
|
||||
if size and volumes is None:
|
||||
volumes = _volumes_by_disk()
|
||||
vols = (volumes or {}).get(n, []) if size else []
|
||||
for v in vols:
|
||||
_fill_volume_info(v)
|
||||
disks.append(Disk(str(n), name, size, bus, vols, has_media=size > 0))
|
||||
return disks
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Elevated Windows SD-card formatter (runs as: python -m kota_tech.sd.win_format ...).
|
||||
|
||||
Replaces Clear-Disk / New-Partition / Format-Volume: every Storage cmdlet
|
||||
goes through the storage provider, which stalls ~13 s on an empty card
|
||||
reader slot, several times per format. This helper touches only the chosen
|
||||
disk through its own driver:
|
||||
|
||||
1. guard: bus is USB/SD/MMC, size is exactly the one the technician
|
||||
confirmed, and the disk does not hold the Windows drive;
|
||||
2. lock + dismount the card's volumes;
|
||||
3. raw writes: zero old MBR/GPT, FAT32 structures (kota_tech.sd.fat32),
|
||||
then the new MBR last;
|
||||
4. unlock, let Windows re-read the disk, wait for the FAT32 'KOTA' volume;
|
||||
5. read-only `chkdsk` of the new volume.
|
||||
|
||||
The result (with per-step timings) goes to a JSON file for the non-elevated
|
||||
tool, which then writes ENV.TXT through the normal file API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
|
||||
from . import fat32, win_disks
|
||||
|
||||
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
|
||||
FSCTL_LOCK_VOLUME, FSCTL_UNLOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x0009001C, 0x00090020
|
||||
IOCTL_DISK_UPDATE_PROPERTIES = 0x00070140
|
||||
CARD_BUSES = {"USB", "SD", "MMC"}
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
k32 = win_disks._k32
|
||||
k32.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||||
k32.SetFilePointerEx.argtypes = [wintypes.HANDLE, ctypes.c_longlong, ctypes.POINTER(ctypes.c_longlong), wintypes.DWORD]
|
||||
k32.VirtualAlloc.restype = wintypes.LPVOID
|
||||
k32.VirtualAlloc.argtypes = [wintypes.LPVOID, ctypes.c_size_t, wintypes.DWORD, wintypes.DWORD]
|
||||
|
||||
|
||||
class FormatError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _open_rw(path: str):
|
||||
h = k32.CreateFileW(path, GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
|
||||
if h in (None, win_disks._INVALID):
|
||||
raise FormatError(f"cannot open {path} (error {ctypes.get_last_error()})")
|
||||
return h
|
||||
|
||||
|
||||
def _ioctl(h, code: int) -> bool:
|
||||
got = wintypes.DWORD()
|
||||
return bool(k32.DeviceIoControl(h, code, None, 0, None, 0, ctypes.byref(got), None))
|
||||
|
||||
|
||||
class RawDisk:
|
||||
"""Sector-aligned writes through a page-aligned buffer (device alignment rules)."""
|
||||
|
||||
def __init__(self, number: int, bps: int):
|
||||
self.h = _open_rw(rf"\\.\PhysicalDrive{number}")
|
||||
self.bps = bps
|
||||
self.buf = k32.VirtualAlloc(None, CHUNK, 0x3000, 0x04) # MEM_COMMIT|RESERVE, RW
|
||||
|
||||
def write(self, offset: int, data: bytes) -> None:
|
||||
if offset % self.bps or len(data) % self.bps:
|
||||
raise FormatError("unaligned write")
|
||||
pos = 0
|
||||
while pos < len(data):
|
||||
n = min(CHUNK, len(data) - pos)
|
||||
ctypes.memmove(self.buf, data[pos:pos + n], n)
|
||||
self._raw(offset + pos, n)
|
||||
pos += n
|
||||
|
||||
def zero(self, offset: int, length: int) -> None:
|
||||
ctypes.memset(self.buf, 0, CHUNK)
|
||||
pos = 0
|
||||
while pos < length:
|
||||
n = min(CHUNK, length - pos)
|
||||
self._raw(offset + pos, n)
|
||||
pos += n
|
||||
|
||||
def _raw(self, offset: int, n: int) -> None:
|
||||
if not k32.SetFilePointerEx(self.h, offset, None, 0):
|
||||
raise FormatError(f"seek failed (error {ctypes.get_last_error()})")
|
||||
done = wintypes.DWORD()
|
||||
if not k32.WriteFile(self.h, self.buf, n, ctypes.byref(done), None) or done.value != n:
|
||||
raise FormatError(f"write at {offset} failed (error {ctypes.get_last_error()}) - write-protected card?")
|
||||
|
||||
def close(self) -> None:
|
||||
k32.FlushFileBuffers(self.h)
|
||||
_ioctl(self.h, IOCTL_DISK_UPDATE_PROPERTIES) # re-read the new partition table
|
||||
k32.CloseHandle(self.h)
|
||||
|
||||
|
||||
def format_card(number: int, expected_size: int, label: str, timings: dict) -> str:
|
||||
t0 = time.perf_counter()
|
||||
disk = next((d for d in win_disks.list_disks(CARD_BUSES) if d.id == str(number)), None)
|
||||
if disk is None:
|
||||
raise FormatError("Refusing: the disk is not a USB/SD/MMC card reader.")
|
||||
if disk.size != expected_size:
|
||||
raise FormatError("Refusing: the disk changed since it was selected (card swapped?).")
|
||||
system = os.environ.get("SystemDrive", "C:").upper().rstrip("\\") + "\\"
|
||||
if any(v.mount.upper() == system for v in disk.volumes):
|
||||
raise FormatError("Refusing: this disk holds the Windows drive.")
|
||||
timings["guard"] = time.perf_counter() - t0
|
||||
|
||||
t = time.perf_counter()
|
||||
locked = []
|
||||
for v in disk.volumes:
|
||||
h = _open_rw(rf"\\.\{v.mount[0]}:")
|
||||
for _ in range(20):
|
||||
if _ioctl(h, FSCTL_LOCK_VOLUME):
|
||||
break
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
k32.CloseHandle(h)
|
||||
raise FormatError(f"{v.mount} is in use by another program - close it and try again.")
|
||||
_ioctl(h, FSCTL_DISMOUNT_VOLUME)
|
||||
locked.append(h)
|
||||
timings["lock_volumes"] = time.perf_counter() - t
|
||||
|
||||
t = time.perf_counter()
|
||||
lay = fat32.plan(disk.size)
|
||||
zeros, writes = fat32.build(lay, label, random.getrandbits(32), random.getrandbits(32), disk.size)
|
||||
raw = RawDisk(number, lay.bytes_per_sector)
|
||||
try:
|
||||
for off, n in zeros:
|
||||
raw.zero(off, n)
|
||||
for off, data in writes: # MBR is last: the partition appears complete
|
||||
raw.write(off, data)
|
||||
finally:
|
||||
for h in locked:
|
||||
_ioctl(h, FSCTL_UNLOCK_VOLUME)
|
||||
k32.CloseHandle(h)
|
||||
raw.close()
|
||||
timings["write"] = time.perf_counter() - t
|
||||
|
||||
t = time.perf_counter()
|
||||
drive = ""
|
||||
for _ in range(80): # Windows mounts the new volume by itself
|
||||
d = next((x for x in win_disks.list_disks(CARD_BUSES) if x.id == str(number)), None)
|
||||
vol = next((v for v in (d.volumes if d else []) if v.filesystem.upper() == "FAT32"), None)
|
||||
if vol and vol.label.upper() == label.upper():
|
||||
drive = vol.mount
|
||||
break
|
||||
time.sleep(0.25)
|
||||
timings["mount"] = time.perf_counter() - t
|
||||
if not drive:
|
||||
raise FormatError("Formatted, but Windows did not mount the card - remove and re-insert it.")
|
||||
|
||||
t = time.perf_counter()
|
||||
chk = subprocess.run(["chkdsk", drive.rstrip("\\")], capture_output=True, text=True, timeout=120,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
timings["chkdsk"] = time.perf_counter() - t
|
||||
if chk.returncode != 0:
|
||||
tail = " ".join(chk.stdout.split()[-30:])
|
||||
raise FormatError(f"chkdsk reported problems on the new file system: {tail}")
|
||||
return drive
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--disk", type=int, required=True)
|
||||
ap.add_argument("--size", type=int, required=True)
|
||||
ap.add_argument("--label", default="KOTA")
|
||||
ap.add_argument("--result", required=True)
|
||||
a = ap.parse_args(argv)
|
||||
timings: dict[str, float] = {}
|
||||
result = {"ok": False, "message": "", "drive": "", "timings": timings}
|
||||
try:
|
||||
result["drive"] = format_card(a.disk, a.size, a.label, timings)
|
||||
result["ok"] = True
|
||||
result["message"] = "Formatted FAT32 (MBR), checked with chkdsk."
|
||||
except FormatError as exc:
|
||||
result["message"] = str(exc)
|
||||
except Exception as exc: # report everything to the tool
|
||||
result["message"] = f"Unexpected error: {exc!r}"
|
||||
with open(a.result, "w", encoding="utf-8") as fh:
|
||||
json.dump(result, fh)
|
||||
return 0 if result["ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user