Technician tool v0.8
Imported from the Google Drive folder 004-technician_tool/v0.8.
This commit is contained in:
1 parent
dd915b5a74
commit
bfa7edce6f
13 files changed
+406
-295
No files matched your search
@@ -1,3 +1,3 @@
|
||||
"""KOTA sensorbox technician tool."""
|
||||
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "0.8.0"
|
||||
@@ -137,6 +137,14 @@ id = "benign.zcl"
|
||||
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
|
||||
severity = "ignore"
|
||||
|
||||
[[rule]]
|
||||
id = "benign.wifi_start"
|
||||
# Harmless Wi-Fi noise seen on every 1.0.16 boot: Matter asks for a connect
|
||||
# while the station is already connecting, and an unpaired box polls a
|
||||
# network it does not have yet (matter.not_commissioned covers that case).
|
||||
pattern = 'wifi:sta is connecting, return error|esp_wifi_connect\(\) failed: ESP_ERR_WIFI_CONN|Failed to get configured network when updating network status'
|
||||
severity = "ignore"
|
||||
|
||||
[[rule]]
|
||||
id = "benign.connect_chain"
|
||||
# Transport-level echo of a failure that a more specific rule reports.
|
||||
|
||||
+261
-260
@@ -1,260 +1,261 @@
|
||||
"""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 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).
|
||||
"""
|
||||
|
||||
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 Disk
|
||||
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
|
||||
|
||||
VOLUME_LABEL = "KOTA"
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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 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:
|
||||
result = _workdir() / "result.json"
|
||||
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
|
||||
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"))
|
||||
except (OSError, ValueError) as exc:
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
"""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 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).
|
||||
"""
|
||||
|
||||
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 Disk
|
||||
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
|
||||
|
||||
VOLUME_LABEL = "KOTA"
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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 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:
|
||||
result = _workdir() / "result.json"
|
||||
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
|
||||
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"))
|
||||
except (OSError, ValueError) as exc:
|
||||
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)
|
||||
|
||||
|
||||
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] = []
|
||||
# Windows says "FAT32"; macOS diskutil says "DOS_FAT_32" / "Windows_FAT_32" / "MS-DOS FAT32".
|
||||
fs = filesystem.upper().replace("_", "").replace(" ", "")
|
||||
if fs and "FAT32" not in fs and fs not in ("FAT", "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