Technician tool v0.5

Imported from the Google Drive folder 004-technician_tool/v0.5.
This commit is contained in:
kkallas committed 2026-09-26 18:30:51 +02:00
1 parent 72adfe1cec
commit f5b32e1554
21 files changed
+2036 -442

No files matched your search

+273
View File
@@ -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)