Technician tool v0.5
Imported from the Google Drive folder 004-technician_tool/v0.5.
This commit is contained in:
1 parent
72adfe1cec
commit
f5b32e1554
21 files changed
+2036
-442
No files matched your search
@@ -0,0 +1,273 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user