274 lines
13 KiB
Python
274 lines
13 KiB
Python
"""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"<plist><dict/></plist>")
|
|
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)
|