"""Format an SD card for the sensorbox and write / check ENV.TXT. 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). macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh). """ from __future__ import annotations import json import os import plistlib import subprocess import sys import time import uuid from dataclasses import dataclass, field from pathlib import Path from .disks import CARD_BUSES, Disk from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env VOLUME_LABEL = "KOTA" FAT32_MAX = 32 * 1024**3 @dataclass class Outcome: ok: bool message: str mount: str = "" log: list[str] = field(default_factory=list) @dataclass class CardReport: mount: str filesystem: str env: EnvCheck | None buffered_lines: int | None # measurements waiting on the card for upload problems: list[str] # ----------------------------------------------------------------- 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" def _workdir() -> Path: # Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a # file written there by the ELEVATED script is then unreadable for this # (non-elevated) process. A normally inherited ACL works for both. work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}" work.mkdir(parents=True, exist_ok=False) 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 _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") 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) try: res = json.loads(result.read_text(encoding="utf-8-sig")) 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(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log) def _eject_windows(mount: str) -> bool: """Flush, lock, dismount and eject the card - what 'Safely Remove' does. Not Shell.Application InvokeVerb('Eject'): it fails silently because the verb is named 'E&ject' (and is localised on non-English Windows). """ import ctypes from ctypes import wintypes 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] GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3 FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020 IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808 handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None) if handle in (None, wintypes.HANDLE(-1).value): return False ret = wintypes.DWORD() def ioctl(code: int, buf=None, size: int = 0) -> bool: return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None)) try: k32.FlushFileBuffers(handle) for _ in range(10): # Explorer/indexer may hold it briefly if ioctl(FSCTL_LOCK_VOLUME): break time.sleep(0.5) else: return False if not ioctl(FSCTL_DISMOUNT_VOLUME): return False allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE} ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1) ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices return True finally: k32.CloseHandle(handle) # ------------------------------------------------------------------- macOS def _format_macos(disk: Disk, log: list[str]) -> Outcome: log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}") proc = subprocess.run(["diskutil", "eraseDisk", "FAT32", VOLUME_LABEL, "MBRFormat", disk.id], capture_output=True, text=True, timeout=600) log += [l for l in proc.stdout.splitlines() if l.strip()] if proc.returncode != 0: return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:]) + " (is the card's write-protect switch on?)", log=log) part = f"{disk.id}s1" for _ in range(20): # wait for the volume to mount info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"") if info.get("MountPoint"): return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log) time.sleep(0.5) return Outcome(False, "Formatted, but the card did not mount.", log=log) def _eject_macos(disk_id: str) -> bool: return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0 # ------------------------------------------------------------------ common def write_env(mount: str, env: EnvConfig) -> Outcome: """Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back.""" problems = [i.text for i in env.problems() if i.level == "error"] if problems: return Outcome(False, "Settings are not valid: " + " ".join(problems)) root = Path(mount) if not root.is_dir(): return Outcome(False, f"Card not found at {mount}.") old = find_case_insensitive(root, ENV_NAME) if old is not None and old.name != ENV_NAME: old.unlink() # replace env.txt / Env.txt variants target = root / ENV_NAME data = env.render() with open(target, "wb") as fh: fh.write(data) fh.flush() os.fsync(fh.fileno()) if target.read_bytes() != data: return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.") check = parse_env(target.read_bytes()) if not check.ok: return Outcome(False, "ENV.TXT was written but does not validate: " + " ".join(i.text for i in check.issues if i.level == "error")) return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount) def format_and_write(disk: Disk, env: EnvConfig, eject_card: bool = True) -> Outcome: problems = [i.text for i in env.problems() if i.level == "error"] if problems: return Outcome(False, "Settings are not valid: " + " ".join(problems)) log: list[str] = [] if sys.platform == "win32": fmt = _format_windows(disk, log) elif sys.platform == "darwin": fmt = _format_macos(disk, log) else: return Outcome(False, "SD provisioning is supported on Windows and macOS only.") if not fmt.ok: return fmt wr = write_env(fmt.mount, env) log.append(wr.message) if not wr.ok: return Outcome(False, wr.message, fmt.mount, log) if eject_card: log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else "Could not eject the card automatically - use 'Safely remove' before pulling it out.") return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log) def eject(mount: str, disk: Disk | None = None) -> bool: """True if the card was dismounted and can be pulled out safely.""" if sys.platform == "win32": return _eject_windows(mount) if sys.platform == "darwin" and disk is not None: return _eject_macos(disk.id) return False def check_card(mount: str, filesystem: str = "") -> CardReport: """What the box would find on this card (values masked in the UI).""" root = Path(mount) problems: list[str] = [] fs = filesystem.upper() if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"): problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.") env_file = find_case_insensitive(root, ENV_NAME) env = parse_env(env_file.read_bytes()) if env_file else None if env_file is None: problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).") elif len(env_file.name.split(".")[0]) > 8: problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.") buf = find_case_insensitive(root, BUFFER_NAME) lines = None if buf is not None: with open(buf, "rb") as fh: lines = sum(1 for ln in fh if ln.strip()) return CardReport(mount, filesystem, env, lines, problems)