Technician tool v0.8

Imported from the Google Drive folder 004-technician_tool/v0.8.
This commit is contained in:
kkallas committed 2026-09-28 19:19:55 +02:00
1 parent dd915b5a74
commit bfa7edce6f
13 files changed
+406 -295

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# KOTA Sensorbox Technician Tool v0.8
Diagnoza sensorboxa przez USB, wgrywanie firmware z folderu `firmware/` i przygotowanie karty SD.
Działa na Windows 10/11 i macOS 13 (Ventura) lub nowszym, na Macach z Intelem i Apple Silicon.
## Uruchomienie
**Windows:** dwuklik `run_windows.bat`.
**macOS:** dwuklik `run_macos.command`.
Jeśli Finder odpowie, że pliku nie można uruchomić (Google Drive gubi uprawnienie „wykonywalny”),
otwórz Terminal i jednorazowo wpisz `bash ` (ze spacją), przeciągnij plik `run_macos.command` do okna i naciśnij Enter.
Na stałe naprawia to: `chmod +x run_macos.command`.
Przy pierwszym uruchomieniu skrypt proponuje instalację `uv` (menedżer Pythona, instaluje się tylko dla bieżącego
użytkownika) i pobiera biblioteki. Potrzebny jest internet, trwa to 1–2 minuty. Kolejne starty są natychmiastowe.
Środowisko jest poza folderem Google Drive:
`%LOCALAPPDATA%\KotaTechnicianTool\venv-0.8` (Windows), `~/Library/Caches/KotaTechnicianTool/venv-0.8` (macOS).
Sesje, kopie zapasowe flash i ustawienia są w `~/KotaTechnicianTool` (Windows: `C:\Users\<użytkownik>\KotaTechnicianTool`).
## Port sensorboxa
- Windows: `COMx - Sensorbox (native USB) [MAC]`
- macOS: `/dev/cu.usbmodemXXXX - Sensorbox (native USB) [MAC]`
Kabel USB musi przenosić dane (nie tylko ładowanie). Przy restartach z komunikatem o brownout użyj innego portu
lub zasilacza.
## Firmware
Wgrać można tylko pliki `.bin` z folderu `firmware/`. Lista pokazuje datę kompilacji zapisaną w pliku,
najnowszy build jest na górze. Nowy build: wrzuć plik do `firmware/` i kliknij **Refresh list**.
## Testy (dla zespołu)
```
uv run --frozen pytest -q
```
Zmiany: `docs/CHANGES-v0.7.md`, `docs/CHANGES-v0.8.md`.
+21
View File
@@ -0,0 +1,21 @@
# Technician tool v0.8 - changes
## Windows + macOS
- Dependencies verified to install on macOS arm64 and x86_64 (uv.lock: PySide6 6.11 universal2 wheels,
requires macOS 13+); Windows unchanged.
- SD card check: macOS `diskutil` reports FAT32 partitions as `DOS_FAT_32` / `Windows_FAT_32`; these are now
accepted (previously a correct card was reported as "not FAT32" on a Mac).
- Unit tests for the macOS serial-port filter (`/dev/cu.*` only) and FAT32 names.
- README.md with launch steps for both systems (incl. the Google Drive executable-bit workaround on macOS);
docs/mac_agent_command.txt updated for a v0.8 test run on a Mac.
## Diagnosis
- Harmless Wi-Fi start-up errors are ignored (rule `benign.wifi_start`): `wifi:sta is connecting, return error`,
`esp_wifi_connect() failed: ESP_ERR_WIFI_CONN`, `Failed to get configured network when updating network
status` (unpaired box).
- Brown-out resets are detected (`E BOD: Brownout detector was triggered`, ROM reports RTC_SW_SYS_RST).
- Service report note = the Diagnosis panel as text (LED, firmware, box self-report, start-up progress with
times, findings with "Seen Nx, first at ... s, last at ... s" and log line), shortened to fit the service
form's 5000 characters.
Not yet run on a real Mac: see docs/mac_agent_command.txt.
+35 -30
View File
@@ -1,30 +1,35 @@
You are testing the KOTA sensorbox technician tool v0.5 on macOS. The current folder is You are testing the KOTA sensorbox technician tool v0.8 on macOS. The current folder is
004-technician_tool/v0.5 (a Python/PySide6 desktop app; it has only been tested on Windows so far). 004-technician_tool/v0.8 (a Python/PySide6 desktop app; tested on Windows 11, macOS paths are covered by
unit tests but not yet run on a real Mac).
Rules:
- Python only via uv or conda/mamba. Never use bare/system python or pip. Rules:
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin. - Python only via uv or conda/mamba. Never use bare/system python or pip.
- Do not push anything to Git or GitLab. This is a local demo. - Do not build or modify firmware. Flash only files from the tool's firmware/ folder.
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files. - Do not push anything to Git or GitLab. This is a local demo.
Before formatting, show me the exact disk (identifier, name, size, current files). - Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
- Do not change code without telling me. Report bugs with evidence (commands and output). Before formatting, show me the exact disk (identifier, name, size, current files).
- Do not change code without telling me. Report bugs with evidence (commands and output).
Tests, in order. Report pass/fail with evidence for each:
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings. Tests, in order. Report pass/fail with evidence for each:
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q` 1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)" Also check whether a double-click in Finder works (Google Drive may drop the executable bit).
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the 2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8" uv run --frozen pytest -q`
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the (expect 172 passed).
reset and whether the tool reconnects. 3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows. entry with a MAC (no /dev/tty.* duplicate). Connect, press "Reset & capture": the log starts at
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself, "ESP-ROM:esp32s3" and the Diagnosis panel shows firmware v1.0.16 (build 4f10989e), the "Box self-report"
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output. section and the LED with its pattern. Check whether the port disappears after the reset and whether the
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed tool reconnects. Wait 2 minutes: a "KOTA: health" line appears every 60 s.
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT". 4. Show filter "KOTA diagnostics + errors" shows only KOTA lines and errors.
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical 5. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
to the template, and eject the card. 6. Flash (App only, the newest file in firmware/, chosen by its compile date): the tool must enter flashing mode
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine by itself, write, restart the box and report "Success ... build 4f10989e". Capture the esptool output.
if the page itself cannot prefill yet). 7. SD card tab: the card reader and card are listed (internal disks must NOT be listed). "Check card" must NOT
report a wrong file system for a FAT32 card. After my OK, "Format card and write ENV.TXT" must use
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT and eject the card.
suggested fixes (file:line). 8. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=...; the note in
the form must match the Diagnosis panel (self-report, start-up progress with times, findings with
"Seen Nx, first at ... s").
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
suggested fixes (file:line).
+1 -1
View File
@@ -1,3 +1,3 @@
"""KOTA sensorbox technician tool.""" """KOTA sensorbox technician tool."""
__version__ = "0.7.0" __version__ = "0.8.0"
+8
View File
@@ -137,6 +137,14 @@ id = "benign.zcl"
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL' pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
severity = "ignore" 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]] [[rule]]
id = "benign.connect_chain" id = "benign.connect_chain"
# Transport-level echo of a failure that a more specific rule reports. # Transport-level echo of a failure that a more specific rule reports.
+261 -260
View File
@@ -1,260 +1,261 @@
"""Format an SD card for the sensorbox and write / check ENV.TXT. """Format an SD card for the sensorbox and write / check ENV.TXT.
Card format required by the firmware: MBR partition table, FAT32 (its FAT 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. 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 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 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 Windows drive) right before erasing it, so a card swapped after the
technician confirmed is not touched, and it writes MBR + FAT32 itself technician confirmed is not touched, and it writes MBR + FAT32 itself
(no Storage cmdlets, which stalled on empty card-reader slots). Larger cards (no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
get a 32 GB FAT32 partition (Windows convention; plenty for the box). get a 32 GB FAT32 partition (Windows convention; plenty for the box).
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh). macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import plistlib import plistlib
import subprocess import subprocess
import sys import sys
import time import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from .disks import Disk from .disks import Disk
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
VOLUME_LABEL = "KOTA" VOLUME_LABEL = "KOTA"
@dataclass @dataclass
class Outcome: class Outcome:
ok: bool ok: bool
message: str message: str
mount: str = "" mount: str = ""
log: list[str] = field(default_factory=list) log: list[str] = field(default_factory=list)
@dataclass @dataclass
class CardReport: class CardReport:
mount: str mount: str
filesystem: str filesystem: str
env: EnvCheck | None env: EnvCheck | None
buffered_lines: int | None # measurements waiting on the card for upload buffered_lines: int | None # measurements waiting on the card for upload
problems: list[str] problems: list[str]
# ----------------------------------------------------------------- Windows # ----------------------------------------------------------------- Windows
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp" WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
def _workdir() -> Path: def _workdir() -> Path:
# Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a # 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 # file written there by the ELEVATED script is then unreadable for this
# (non-elevated) process. A normally inherited ACL works for both. # (non-elevated) process. A normally inherited ACL works for both.
work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}" work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}"
work.mkdir(parents=True, exist_ok=False) work.mkdir(parents=True, exist_ok=False)
return work return work
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]: def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
"""(program, parameters, working dir) for the elevated helper.""" """(program, parameters, working dir) for the elevated helper."""
root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/ 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)} ' \ params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
f'--label {VOLUME_LABEL} --result "{result}"' f'--label {VOLUME_LABEL} --result "{result}"'
return sys.executable, params, str(root) return sys.executable, params, str(root)
def _run_elevated(program: str, params: str, cwd: str, timeout_s: int = 300) -> int | None: 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.""" """ShellExecuteEx 'runas' + wait. None if the technician refused the UAC prompt."""
import ctypes import ctypes
from ctypes import wintypes from ctypes import wintypes
class SHELLEXECUTEINFOW(ctypes.Structure): class SHELLEXECUTEINFOW(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND), _fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR), ("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE), ("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY), ("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)] ("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0 SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS, info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE) lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
shell32 = ctypes.WinDLL("shell32", use_last_error=True) shell32 = ctypes.WinDLL("shell32", use_last_error=True)
k32 = ctypes.WinDLL("kernel32", use_last_error=True) k32 = ctypes.WinDLL("kernel32", use_last_error=True)
if not shell32.ShellExecuteExW(ctypes.byref(info)): if not shell32.ShellExecuteExW(ctypes.byref(info)):
return None # ERROR_CANCELLED (1223) or failure return None # ERROR_CANCELLED (1223) or failure
k32.WaitForSingleObject(info.hProcess, timeout_s * 1000) k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
code = wintypes.DWORD() code = wintypes.DWORD()
k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code)) k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
k32.CloseHandle(info.hProcess) k32.CloseHandle(info.hProcess)
return code.value return code.value
def _format_windows(disk: Disk, log: list[str]) -> Outcome: def _format_windows(disk: Disk, log: list[str]) -> Outcome:
result = _workdir() / "result.json" result = _workdir() / "result.json"
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...") log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
t = time.perf_counter() t = time.perf_counter()
code = _run_elevated(*win_format_command(disk, result)) code = _run_elevated(*win_format_command(disk, result))
if code is None: if code is None:
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log) return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
try: try:
res = json.loads(result.read_text(encoding="utf-8")) res = json.loads(result.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). " return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
"Check the card with 'Check card'.", log=log) "Check the card with 'Check card'.", log=log)
steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items()) 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})") 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) return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
def _eject_windows(mount: str) -> bool: def _eject_windows(mount: str) -> bool:
"""Flush, lock, dismount and eject the card - what 'Safely Remove' does. """Flush, lock, dismount and eject the card - what 'Safely Remove' does.
Not Shell.Application InvokeVerb('Eject'): it fails silently because the Not Shell.Application InvokeVerb('Eject'): it fails silently because the
verb is named 'E&ject' (and is localised on non-English Windows). verb is named 'E&ject' (and is localised on non-English Windows).
""" """
import ctypes import ctypes
from ctypes import wintypes from ctypes import wintypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True) k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateFileW.restype = wintypes.HANDLE k32.CreateFileW.restype = wintypes.HANDLE
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID, k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD, k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID] wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3 GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020 FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020
IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808 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) handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
if handle in (None, wintypes.HANDLE(-1).value): if handle in (None, wintypes.HANDLE(-1).value):
return False return False
ret = wintypes.DWORD() ret = wintypes.DWORD()
def ioctl(code: int, buf=None, size: int = 0) -> bool: def ioctl(code: int, buf=None, size: int = 0) -> bool:
return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None)) return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None))
try: try:
k32.FlushFileBuffers(handle) k32.FlushFileBuffers(handle)
for _ in range(10): # Explorer/indexer may hold it briefly for _ in range(10): # Explorer/indexer may hold it briefly
if ioctl(FSCTL_LOCK_VOLUME): if ioctl(FSCTL_LOCK_VOLUME):
break break
time.sleep(0.5) time.sleep(0.5)
else: else:
return False return False
if not ioctl(FSCTL_DISMOUNT_VOLUME): if not ioctl(FSCTL_DISMOUNT_VOLUME):
return False return False
allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE} allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE}
ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1) ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1)
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices
return True return True
finally: finally:
k32.CloseHandle(handle) k32.CloseHandle(handle)
# ------------------------------------------------------------------- macOS # ------------------------------------------------------------------- macOS
def _format_macos(disk: Disk, log: list[str]) -> Outcome: def _format_macos(disk: Disk, log: list[str]) -> Outcome:
log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}") log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}")
proc = subprocess.run(["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) capture_output=True, text=True, timeout=600)
log += [l for l in proc.stdout.splitlines() if l.strip()] log += [l for l in proc.stdout.splitlines() if l.strip()]
if proc.returncode != 0: if proc.returncode != 0:
return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:]) return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
+ " (is the card's write-protect switch on?)", log=log) + " (is the card's write-protect switch on?)", log=log)
part = f"{disk.id}s1" part = f"{disk.id}s1"
for _ in range(20): # wait for the volume to mount 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>") info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"<plist><dict/></plist>")
if info.get("MountPoint"): if info.get("MountPoint"):
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log) return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log)
time.sleep(0.5) time.sleep(0.5)
return Outcome(False, "Formatted, but the card did not mount.", log=log) return Outcome(False, "Formatted, but the card did not mount.", log=log)
def _eject_macos(disk_id: str) -> bool: def _eject_macos(disk_id: str) -> bool:
return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0 return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0
# ------------------------------------------------------------------ common # ------------------------------------------------------------------ common
def write_env(mount: str, env: EnvConfig) -> Outcome: def write_env(mount: str, env: EnvConfig) -> Outcome:
"""Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back.""" """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"] problems = [i.text for i in env.problems() if i.level == "error"]
if problems: if problems:
return Outcome(False, "Settings are not valid: " + " ".join(problems)) return Outcome(False, "Settings are not valid: " + " ".join(problems))
root = Path(mount) root = Path(mount)
if not root.is_dir(): if not root.is_dir():
return Outcome(False, f"Card not found at {mount}.") return Outcome(False, f"Card not found at {mount}.")
old = find_case_insensitive(root, ENV_NAME) old = find_case_insensitive(root, ENV_NAME)
if old is not None and old.name != ENV_NAME: if old is not None and old.name != ENV_NAME:
old.unlink() # replace env.txt / Env.txt variants old.unlink() # replace env.txt / Env.txt variants
target = root / ENV_NAME target = root / ENV_NAME
data = env.render() data = env.render()
with open(target, "wb") as fh: with open(target, "wb") as fh:
fh.write(data) fh.write(data)
fh.flush() fh.flush()
os.fsync(fh.fileno()) os.fsync(fh.fileno())
if target.read_bytes() != data: if target.read_bytes() != data:
return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.") return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.")
check = parse_env(target.read_bytes()) check = parse_env(target.read_bytes())
if not check.ok: if not check.ok:
return Outcome(False, "ENV.TXT was written but does not validate: " return Outcome(False, "ENV.TXT was written but does not validate: "
+ " ".join(i.text for i in check.issues if i.level == "error")) + " ".join(i.text for i in check.issues if i.level == "error"))
return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount) 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: 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"] problems = [i.text for i in env.problems() if i.level == "error"]
if problems: if problems:
return Outcome(False, "Settings are not valid: " + " ".join(problems)) return Outcome(False, "Settings are not valid: " + " ".join(problems))
log: list[str] = [] log: list[str] = []
if sys.platform == "win32": if sys.platform == "win32":
fmt = _format_windows(disk, log) fmt = _format_windows(disk, log)
elif sys.platform == "darwin": elif sys.platform == "darwin":
fmt = _format_macos(disk, log) fmt = _format_macos(disk, log)
else: else:
return Outcome(False, "SD provisioning is supported on Windows and macOS only.") return Outcome(False, "SD provisioning is supported on Windows and macOS only.")
if not fmt.ok: if not fmt.ok:
return fmt return fmt
wr = write_env(fmt.mount, env) wr = write_env(fmt.mount, env)
log.append(wr.message) log.append(wr.message)
if not wr.ok: if not wr.ok:
return Outcome(False, wr.message, fmt.mount, log) return Outcome(False, wr.message, fmt.mount, log)
if eject_card: if eject_card:
log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else 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.") "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) return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log)
def eject(mount: str, disk: Disk | None = None) -> bool: def eject(mount: str, disk: Disk | None = None) -> bool:
"""True if the card was dismounted and can be pulled out safely.""" """True if the card was dismounted and can be pulled out safely."""
if sys.platform == "win32": if sys.platform == "win32":
return _eject_windows(mount) return _eject_windows(mount)
if sys.platform == "darwin" and disk is not None: if sys.platform == "darwin" and disk is not None:
return _eject_macos(disk.id) return _eject_macos(disk.id)
return False return False
def check_card(mount: str, filesystem: str = "") -> CardReport: def check_card(mount: str, filesystem: str = "") -> CardReport:
"""What the box would find on this card (values masked in the UI).""" """What the box would find on this card (values masked in the UI)."""
root = Path(mount) root = Path(mount)
problems: list[str] = [] problems: list[str] = []
fs = filesystem.upper() # Windows says "FAT32"; macOS diskutil says "DOS_FAT_32" / "Windows_FAT_32" / "MS-DOS FAT32".
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"): fs = filesystem.upper().replace("_", "").replace(" ", "")
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.") if fs and "FAT32" not in fs and fs not in ("FAT", "MSDOS"):
env_file = find_case_insensitive(root, ENV_NAME) problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
env = parse_env(env_file.read_bytes()) if env_file else None env_file = find_case_insensitive(root, ENV_NAME)
if env_file is None: env = parse_env(env_file.read_bytes()) if env_file else None
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).") if env_file is None:
elif len(env_file.name.split(".")[0]) > 8: problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.") elif len(env_file.name.split(".")[0]) > 8:
buf = find_case_insensitive(root, BUFFER_NAME) problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
lines = None buf = find_case_insensitive(root, BUFFER_NAME)
if buf is not None: lines = None
with open(buf, "rb") as fh: if buf is not None:
lines = sum(1 for ln in fh if ln.strip()) with open(buf, "rb") as fh:
return CardReport(mount, filesystem, env, lines, problems) lines = sum(1 for ln in fh if ln.strip())
return CardReport(mount, filesystem, env, lines, problems)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.7.0" version = "0.8.0"
description = "KOTA sensorbox technician diagnostic tool (demo)" description = "KOTA sensorbox technician diagnostic tool (demo)"
requires-python = ">=3.11,<3.14" requires-python = ">=3.11,<3.14"
dependencies = [ dependencies = [
+3 -1
View File
@@ -2,8 +2,10 @@
# Double-click in Finder, or run: bash run_macos.command # Double-click in Finder, or run: bash run_macos.command
cd "$(dirname "$0")" || exit 1 cd "$(dirname "$0")" || exit 1
# If Finder refuses to open this file (Google Drive drops the executable bit), run it once as:
# bash run_macos.command (or: chmod +x run_macos.command)
# Keep the virtual environment out of the (Google Drive) tool folder. # Keep the virtual environment out of the (Google Drive) tool folder.
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.7" export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8"
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
if ! command -v uv >/dev/null 2>&1; then if ! command -v uv >/dev/null 2>&1; then
+1 -1
View File
@@ -3,7 +3,7 @@ setlocal
cd /d "%~dp0" cd /d "%~dp0"
rem Keep the virtual environment out of the (Google Drive) tool folder. rem Keep the virtual environment out of the (Google Drive) tool folder.
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.7" set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.8"
set "PATH=%USERPROFILE%\.local\bin;%PATH%" set "PATH=%USERPROFILE%\.local\bin;%PATH%"
where uv >nul 2>nul where uv >nul 2>nul
+10
View File
@@ -247,3 +247,13 @@ def test_live_brownout_loop_is_a_power_problem():
assert top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5 assert top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5
assert "reboot.loop" in {c.rule_id for c in top.consequences} assert "reboot.loop" in {c.rule_id for c in top.consequences}
assert r.verdict.startswith("Problem: Power supply") assert r.verdict.startswith("Problem: Power supply")
@pytest.mark.parametrize("line", [
"E (2893) wifi:sta is connecting, return error",
"E (2892) chip[DL]: esp_wifi_connect() failed: ESP_ERR_WIFI_CONN",
"E (5120) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F",
])
def test_harmless_wifi_start_errors_are_not_reported(line):
_, r = run(BOOT + APP + [line])
assert r.roots == [] and "Unrecognised" not in r.to_text()
+12
View File
@@ -12,3 +12,15 @@ def test_other_ports_not_sensorbox():
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None) bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
assert not bt.is_sensorbox and bt.mac is None assert not bt.is_sensorbox and bt.mac is None
assert bt.label == "COM3 - Standard Serial over Bluetooth link" assert bt.label == "COM3 - Standard Serial over Bluetooth link"
def test_macos_lists_only_callout_devices(monkeypatch):
from types import SimpleNamespace
from kota_tech.serialio import ports
fake = [SimpleNamespace(device=d, description="USB JTAG/serial debug unit", vid=0x303A, pid=0x1001,
serial_number="A4:CB:8F:C9:46:F8")
for d in ("/dev/tty.usbmodem1101", "/dev/cu.usbmodem1101")]
monkeypatch.setattr(ports.sys, "platform", "darwin")
monkeypatch.setattr(ports.list_ports, "comports", lambda: fake)
assert [p.device for p in ports.list_serial_ports()] == ["/dev/cu.usbmodem1101"]
+11
View File
@@ -170,3 +170,14 @@ def test_elevated_result_folder_is_not_owner_only(tmp_path, monkeypatch):
assert work.parent == tmp_path / "tmp" and work.is_dir() assert work.parent == tmp_path / "tmp" and work.is_dir()
(work / "result.json").write_text('{"ok": true}', encoding="utf-8") (work / "result.json").write_text('{"ok": true}', encoding="utf-8")
assert (work / "result.json").read_text(encoding="utf-8") assert (work / "result.json").read_text(encoding="utf-8")
@pytest.mark.parametrize("fs", ["FAT32", "DOS_FAT_32", "Windows_FAT_32", "MS-DOS FAT32"])
def test_fat32_names_from_windows_and_macos_are_accepted(tmp_path, fs):
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
assert not any("File system" in p for p in check_card(str(tmp_path), fs).problems)
def test_exfat_is_flagged(tmp_path):
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
assert any("File system" in p for p in check_card(str(tmp_path), "ExFAT").problems)
Generated
+1 -1
View File
@@ -250,7 +250,7 @@ wheels = [
[[package]] [[package]]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.7.0" version = "0.8.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "esptool" }, { name = "esptool" },