Technician tool v0.6
Imported from the Google Drive folder 004-technician_tool/v0.6.
This commit is contained in:
1 parent
f5b32e1554
commit
22bd853ecf
30 files changed
+1521
-568
No files matched your search
@@ -8,4 +8,5 @@ dependencies:
|
|||||||
- pyserial>=3.5
|
- pyserial>=3.5
|
||||||
- pyside6>=6.7
|
- pyside6>=6.7
|
||||||
- esptool>=4.8,<5
|
- esptool>=4.8,<5
|
||||||
|
- segno>=1.6
|
||||||
- pytest>=8
|
- pytest>=8
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Official firmware images the technician tool may flash.
|
|
||||||
# Boxes normally receive new firmware over OTA; this is the factory image a
|
|
||||||
# box updates itself from once it is online. Add a new image only when the
|
|
||||||
# firmware team releases one: copy the .bin here and add an entry with its
|
|
||||||
# SHA-256 (the tool refuses files that do not match).
|
|
||||||
|
|
||||||
[[image]]
|
|
||||||
file = "new_system.bin"
|
|
||||||
sha256 = "1d58a8f4bb60f66009bd6df32e6e860c545c0c668de955af60d049ee3b9a212d"
|
|
||||||
name = "v1.0 factory image (built Jun 29 2026)"
|
|
||||||
notes = "Official factory image. Once online, the box registers for OTA and updates itself."
|
|
||||||
Binary file not shown.
@@ -1,3 +1,3 @@
|
|||||||
"""KOTA sensorbox technician tool."""
|
"""KOTA sensorbox technician tool."""
|
||||||
|
|
||||||
__version__ = "0.5.0"
|
__version__ = "0.6.0"
|
||||||
@@ -280,6 +280,7 @@ class DiagnosticsEngine:
|
|||||||
self._panic_tail = 0 # RAW lines after a panic line to keep
|
self._panic_tail = 0 # RAW lines after a panic line to keep
|
||||||
self.technician: list[str] = []
|
self.technician: list[str] = []
|
||||||
self.mac: str | None = None # from "ID: Device MAC address: ..." at boot
|
self.mac: str | None = None # from "ID: Device MAC address: ..." at boot
|
||||||
|
self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def boot(self) -> BootRecord:
|
def boot(self) -> BootRecord:
|
||||||
@@ -321,6 +322,8 @@ class DiagnosticsEngine:
|
|||||||
self.firmware.observe(text)
|
self.firmware.observe(text)
|
||||||
if m := _MAC_LINE.search(text):
|
if m := _MAC_LINE.search(text):
|
||||||
self.mac = m.group(1).upper()
|
self.mac = m.group(1).upper()
|
||||||
|
if "Advertise commission parameter" in text:
|
||||||
|
self.matter_advert = text
|
||||||
matched = False
|
matched = False
|
||||||
for rule in self.rules:
|
for rule in self.rules:
|
||||||
m = rule.pattern.search(text)
|
m = rule.pattern.search(text)
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ KNOWN: dict[str, KnownFirmware] = {
|
|||||||
sd_wifi=False,
|
sd_wifi=False,
|
||||||
setup_portal=False,
|
setup_portal=False,
|
||||||
),
|
),
|
||||||
|
"c94ba0e9b": KnownFirmware(
|
||||||
|
name="v1.0 source build (Sep 26 2026)",
|
||||||
|
source="developer build, built by Krzysztof Kallas",
|
||||||
|
sd_wifi=True,
|
||||||
|
setup_portal=True,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
|
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class Step:
|
|||||||
skip_if_finding: str | None = None
|
skip_if_finding: str | None = None
|
||||||
skip_unless_feature: str | None = None
|
skip_unless_feature: str | None = None
|
||||||
skip_to: str | None = None
|
skip_to: str | None = None
|
||||||
|
show_qr: bool = False # show the Matter pairing QR next to the step text
|
||||||
|
|
||||||
def targets(self) -> list[str]:
|
def targets(self) -> list[str]:
|
||||||
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
||||||
@@ -101,7 +102,7 @@ def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
|
|||||||
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
|
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
|
||||||
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
||||||
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
|
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
|
||||||
skip_to=s.get("skip_to"),
|
skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
|
||||||
)
|
)
|
||||||
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
||||||
for e in g.get("end", [])}
|
for e in g.get("end", [])}
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ next = "pair_start"
|
|||||||
[[guide.step]]
|
[[guide.step]]
|
||||||
id = "pair_start"
|
id = "pair_start"
|
||||||
type = "wait"
|
type = "wait"
|
||||||
text = "In the Matter app choose 'Add device' and scan the QR code on the sticker (or type the 11-digit code printed under it). Keep the phone within 1 m of the box."
|
text = "In the Matter app choose 'Add device' and scan the QR code shown here (the same as the sticker), or type the 11-digit manual code. Keep the phone within 1 m of the box."
|
||||||
|
show_qr = true
|
||||||
until = 'MAIN: Commissioning session started'
|
until = 'MAIN: Commissioning session started'
|
||||||
timeout_s = 300
|
timeout_s = 300
|
||||||
on_ok = "pair_wifi"
|
on_ok = "pair_wifi"
|
||||||
|
|||||||
+30
-13
@@ -86,6 +86,7 @@ class FirmwareImage:
|
|||||||
app_offset: int # where the app starts inside the file
|
app_offset: int # where the app starts inside the file
|
||||||
app_length: int # exact app image length (from its structure)
|
app_length: int # exact app image length (from its structure)
|
||||||
app: AppInfo
|
app: AppInfo
|
||||||
|
built_by: str = "" # who compiled a developer build (shown in the UI)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data(self) -> bytes:
|
def data(self) -> bytes:
|
||||||
@@ -109,7 +110,10 @@ class FirmwareImage:
|
|||||||
return b"\xff" * self.partition("otadata").size
|
return b"\xff" * self.partition("otadata").size
|
||||||
|
|
||||||
def label(self) -> str:
|
def label(self) -> str:
|
||||||
tag = "factory" if self.origin == "factory" else "developer build"
|
if self.origin == "factory":
|
||||||
|
tag = "factory"
|
||||||
|
else:
|
||||||
|
tag = "developer build" + (f" - built by {self.built_by}" if self.built_by else "")
|
||||||
return f"{self.app.version} built {self.app.built} [{self.app.elf_sha[:9]}] - {tag}"
|
return f"{self.app.version} built {self.app.built} [{self.app.elf_sha[:9]}] - {tag}"
|
||||||
|
|
||||||
|
|
||||||
@@ -181,7 +185,7 @@ def _app_info(data: bytes, offset: int) -> AppInfo:
|
|||||||
|
|
||||||
|
|
||||||
def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "factory",
|
def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "factory",
|
||||||
reference: FirmwareImage | None = None) -> FirmwareImage:
|
reference: FirmwareImage | None = None, built_by: str = "") -> FirmwareImage:
|
||||||
"""Validate a merged or app-only image. App-only images need a `reference`
|
"""Validate a merged or app-only image. App-only images need a `reference`
|
||||||
(the factory image) that provides the flash layout."""
|
(the factory image) that provides the flash layout."""
|
||||||
data = path.read_bytes()
|
data = path.read_bytes()
|
||||||
@@ -208,13 +212,14 @@ def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "fa
|
|||||||
raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)")
|
raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)")
|
||||||
app = _app_info(data, app_off)
|
app = _app_info(data, app_off)
|
||||||
return FirmwareImage(path, name or path.name, notes, origin, kind, hashlib.sha256(data).hexdigest(),
|
return FirmwareImage(path, name or path.name, notes, origin, kind, hashlib.sha256(data).hexdigest(),
|
||||||
flash_size, parts, table, app_off, length, app)
|
flash_size, parts, table, app_off, length, app, built_by)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- catalogue
|
# ------------------------------------------------------------- catalogue
|
||||||
|
|
||||||
def factory_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
def bundled_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
||||||
"""Bundled images whose file exists and matches the SHA-256 in the manifest."""
|
"""Images shipped with the tool whose file exists and matches its SHA-256.
|
||||||
|
Factory images first; bundled developer builds keep their origin/built_by."""
|
||||||
if not manifest.exists():
|
if not manifest.exists():
|
||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
@@ -222,11 +227,19 @@ def factory_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
|||||||
path = manifest.parent / e["file"]
|
path = manifest.parent / e["file"]
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
continue
|
continue
|
||||||
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "factory")
|
origin = e.get("origin", "factory")
|
||||||
|
if origin not in ("factory", "developer"):
|
||||||
|
raise ImageError(f"{path.name}: unknown origin {origin!r} in the manifest")
|
||||||
|
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), origin, built_by=e.get("built_by", ""))
|
||||||
if img.sha256 != e["sha256"].lower():
|
if img.sha256 != e["sha256"].lower():
|
||||||
raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt")
|
raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt")
|
||||||
out.append(img)
|
out.append(img)
|
||||||
return out
|
return sorted(out, key=lambda i: i.origin != "factory")
|
||||||
|
|
||||||
|
|
||||||
|
def factory_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
||||||
|
"""Only the official factory image(s)."""
|
||||||
|
return [i for i in bundled_images(manifest) if i.origin == "factory"]
|
||||||
|
|
||||||
|
|
||||||
class FirmwareLibrary:
|
class FirmwareLibrary:
|
||||||
@@ -249,7 +262,8 @@ class FirmwareLibrary:
|
|||||||
for e in self._index():
|
for e in self._index():
|
||||||
path = self.root / e["file"]
|
path = self.root / e["file"]
|
||||||
try:
|
try:
|
||||||
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "developer", reference)
|
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "developer", reference,
|
||||||
|
e.get("built_by", ""))
|
||||||
if img.sha256 != e["sha256"]:
|
if img.sha256 != e["sha256"]:
|
||||||
raise ImageError("file changed since it was imported")
|
raise ImageError("file changed since it was imported")
|
||||||
imgs.append(img)
|
imgs.append(img)
|
||||||
@@ -257,21 +271,24 @@ class FirmwareLibrary:
|
|||||||
problems.append(f"{e.get('original', e['file'])}: {exc}")
|
problems.append(f"{e.get('original', e['file'])}: {exc}")
|
||||||
return imgs, problems
|
return imgs, problems
|
||||||
|
|
||||||
def import_file(self, src: Path, reference: FirmwareImage | None, notes: str = "") -> FirmwareImage:
|
def import_file(self, src: Path, reference: FirmwareImage | None, notes: str = "",
|
||||||
|
built_by: str = "") -> FirmwareImage:
|
||||||
img = inspect_image(src, origin="developer", reference=reference) # validate before copying
|
img = inspect_image(src, origin="developer", reference=reference) # validate before copying
|
||||||
entries = self._index()
|
entries = self._index()
|
||||||
existing = next((e for e in entries if e["sha256"] == img.sha256), None)
|
existing = next((e for e in entries if e["sha256"] == img.sha256), None)
|
||||||
if existing:
|
if existing:
|
||||||
return inspect_image(self.root / existing["file"], existing["name"], existing["notes"],
|
return inspect_image(self.root / existing["file"], existing["name"], existing["notes"],
|
||||||
"developer", reference)
|
"developer", reference, existing.get("built_by", ""))
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
fname = f"{img.app.elf_sha[:9]}_{img.sha256[:8]}.bin"
|
fname = f"{img.app.elf_sha[:9]}_{img.sha256[:8]}.bin"
|
||||||
shutil.copyfile(src, self.root / fname)
|
shutil.copyfile(src, self.root / fname)
|
||||||
name = f"Developer build {img.app.version} ({img.app.built})"
|
built_by = built_by.strip()
|
||||||
|
name = f"Developer build {img.app.version} ({img.app.built})" + (f", built by {built_by}" if built_by else "")
|
||||||
entries.append({"file": fname, "sha256": img.sha256, "name": name, "original": src.name,
|
entries.append({"file": fname, "sha256": img.sha256, "name": name, "original": src.name,
|
||||||
"imported": dt.datetime.now().isoformat(timespec="seconds"), "notes": notes})
|
"imported": dt.datetime.now().isoformat(timespec="seconds"), "notes": notes,
|
||||||
|
"built_by": built_by})
|
||||||
self._save(entries)
|
self._save(entries)
|
||||||
return inspect_image(self.root / fname, name, notes, "developer", reference)
|
return inspect_image(self.root / fname, name, notes, "developer", reference, built_by)
|
||||||
|
|
||||||
def remove(self, sha256: str) -> None:
|
def remove(self, sha256: str) -> None:
|
||||||
entries = self._index()
|
entries = self._index()
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1,96 @@
|
|||||||
|
"""Matter onboarding payloads: the "MT:..." QR string and the 11-digit manual code.
|
||||||
|
|
||||||
|
Implements the Matter spec (5.1.3 QR payload, 5.1.4 manual pairing code):
|
||||||
|
bit fields packed LSB-first, Base38 in 3-byte chunks, Verhoeff check digit.
|
||||||
|
|
||||||
|
The sensorbox firmware uses the Matter SDK example commissionable data
|
||||||
|
(CONFIG_EXAMPLE_COMMISSIONABLE_DATA_PROVIDER): every v1.0 box advertises
|
||||||
|
VID 0xFFF1 / PID 0x8000 / discriminator 3840 with passcode 20202021, which is
|
||||||
|
exactly the sticker (QR "MT:Y.K9042C00KA0648G00", manual 3497-011-2332).
|
||||||
|
VID/PID/discriminator are taken from the box's own log when available; the
|
||||||
|
passcode is never logged, so the known default is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
BASE38 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-."
|
||||||
|
RENDEZVOUS_BLE = 1 << 1
|
||||||
|
DEFAULT_PASSCODE = 20202021 # Matter SDK test passcode used by the v1.0 firmware
|
||||||
|
DEFAULT = dict(vendor_id=0xFFF1, product_id=0x8000, discriminator=3840)
|
||||||
|
|
||||||
|
# chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
|
||||||
|
_ADVERTISE = re.compile(r"vendorID=(\d+) productID=(\d+) discriminator=(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SetupPayload:
|
||||||
|
vendor_id: int = DEFAULT["vendor_id"]
|
||||||
|
product_id: int = DEFAULT["product_id"]
|
||||||
|
discriminator: int = DEFAULT["discriminator"]
|
||||||
|
passcode: int = DEFAULT_PASSCODE
|
||||||
|
rendezvous: int = RENDEZVOUS_BLE
|
||||||
|
flow: int = 0 # standard commissioning flow
|
||||||
|
version: int = 0
|
||||||
|
from_log: bool = False # VID/PID/discriminator read from this box's log
|
||||||
|
|
||||||
|
def qr_text(self) -> str:
|
||||||
|
fields = [(self.version, 3), (self.vendor_id, 16), (self.product_id, 16), (self.flow, 2),
|
||||||
|
(self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)]
|
||||||
|
value, shift = 0, 0
|
||||||
|
for v, bits in fields:
|
||||||
|
if not 0 <= v < (1 << bits):
|
||||||
|
raise ValueError(f"field value {v} does not fit in {bits} bits")
|
||||||
|
value |= v << shift
|
||||||
|
shift += bits
|
||||||
|
return "MT:" + _base38(value.to_bytes(shift // 8, "little"))
|
||||||
|
|
||||||
|
def manual_code(self) -> str:
|
||||||
|
short = self.discriminator >> 8 # upper 4 bits
|
||||||
|
chunk1 = short >> 2 # vid_pid_present = 0
|
||||||
|
chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF)
|
||||||
|
chunk3 = self.passcode >> 14
|
||||||
|
digits = f"{chunk1}{chunk2:05d}{chunk3:04d}"
|
||||||
|
return digits + str(verhoeff_check(digits))
|
||||||
|
|
||||||
|
def manual_code_pretty(self) -> str:
|
||||||
|
c = self.manual_code()
|
||||||
|
return f"{c[:4]}-{c[4:7]}-{c[7:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _base38(data: bytes) -> str:
|
||||||
|
out = []
|
||||||
|
for i in range(0, len(data), 3):
|
||||||
|
chunk = data[i:i + 3]
|
||||||
|
value = int.from_bytes(chunk, "little")
|
||||||
|
for _ in range({3: 5, 2: 4, 1: 2}[len(chunk)]):
|
||||||
|
value, rem = divmod(value, 38)
|
||||||
|
out.append(BASE38[rem])
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
_D = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
||||||
|
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
||||||
|
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
||||||
|
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]]
|
||||||
|
_P = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
||||||
|
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
||||||
|
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8]]
|
||||||
|
_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]
|
||||||
|
|
||||||
|
|
||||||
|
def verhoeff_check(digits: str) -> int:
|
||||||
|
c = 0
|
||||||
|
for i, d in enumerate(reversed(digits)):
|
||||||
|
c = _D[c][_P[(i + 1) % 8][int(d)]]
|
||||||
|
return _INV[c]
|
||||||
|
|
||||||
|
|
||||||
|
def payload_from_log(lines: list[str]) -> SetupPayload:
|
||||||
|
"""Use the box's advertised VID/PID/discriminator if seen; defaults otherwise."""
|
||||||
|
for text in reversed(lines):
|
||||||
|
if m := _ADVERTISE.search(text):
|
||||||
|
return SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True)
|
||||||
|
return SetupPayload()
|
||||||
@@ -111,10 +111,10 @@ def parse_macos(list_plist: bytes, infos: dict[str, bytes]) -> list[Disk]:
|
|||||||
def list_cards() -> tuple[list[Disk], list[Disk]]:
|
def list_cards() -> tuple[list[Disk], list[Disk]]:
|
||||||
"""(cards that may be formatted, readers/disks shown but not offered)."""
|
"""(cards that may be formatted, readers/disks shown but not offered)."""
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
out = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", WIN_LIST_PS],
|
# Driver-level listing (milliseconds, 1.5 s worst case) instead of
|
||||||
capture_output=True, text=True, timeout=90,
|
# Get-Disk/WMI, which stalled 5-21 s on an empty card-reader slot.
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW).stdout
|
from .win_disks import list_disks
|
||||||
disks = parse_windows(out)
|
disks = list_disks(CARD_BUSES)
|
||||||
elif sys.platform == "darwin":
|
elif sys.platform == "darwin":
|
||||||
lst = subprocess.run(["diskutil", "list", "-plist", "physical"], capture_output=True, timeout=30).stdout
|
lst = subprocess.run(["diskutil", "list", "-plist", "physical"], capture_output=True, timeout=30).stdout
|
||||||
infos = {}
|
infos = {}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""FAT32 + MBR layout for an SD card, as raw byte writes (no OS formatter).
|
||||||
|
|
||||||
|
Follows the Microsoft FAT specification (fatgen103): BPB at sector 0,
|
||||||
|
FSInfo at 1, backup boot sector at 6, two FATs, root directory in cluster 2
|
||||||
|
holding the volume label. Cluster sizes follow Windows' defaults. The
|
||||||
|
partition starts at 1 MiB (SD erase-block friendly), MBR type 0x0C
|
||||||
|
(FAT32 LBA), which is what the firmware's FatFs expects.
|
||||||
|
|
||||||
|
Pure functions: the result is a list of (offset, bytes) writes plus zero
|
||||||
|
ranges relative to the start of the DISK, so it can be tested on a buffer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
PART_START = 1024 * 1024
|
||||||
|
FAT32_MAX_BYTES = 32 * 1024**3 # Windows convention, and all the box needs
|
||||||
|
MIN_CLUSTERS, MAX_CLUSTERS = 65525, 0x0FFFFFF5
|
||||||
|
RESERVED = 32
|
||||||
|
NUM_FATS = 2
|
||||||
|
MEDIA = 0xF8
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Fat32Layout:
|
||||||
|
bytes_per_sector: int
|
||||||
|
part_start: int # bytes from disk start
|
||||||
|
part_sectors: int
|
||||||
|
sectors_per_cluster: int
|
||||||
|
fat_sectors: int
|
||||||
|
clusters: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_start(self) -> int: # sectors from partition start
|
||||||
|
return RESERVED + NUM_FATS * self.fat_sectors
|
||||||
|
|
||||||
|
|
||||||
|
def _spc(sectors: int, bps: int) -> int:
|
||||||
|
size = sectors * bps
|
||||||
|
for limit, cluster in ((260 * 1024**2, 512), (8 * 1024**3, 4096), (16 * 1024**3, 8192),
|
||||||
|
(32 * 1024**3, 16384)):
|
||||||
|
if size <= limit:
|
||||||
|
return max(1, cluster // bps)
|
||||||
|
return max(1, 32768 // bps)
|
||||||
|
|
||||||
|
|
||||||
|
def plan(disk_bytes: int, bps: int = 512) -> Fat32Layout:
|
||||||
|
part_bytes = min(disk_bytes - PART_START, FAT32_MAX_BYTES)
|
||||||
|
part_bytes -= part_bytes % (1024 * 1024) # whole MiB
|
||||||
|
sectors = part_bytes // bps
|
||||||
|
if sectors <= 0:
|
||||||
|
raise ValueError("card is too small")
|
||||||
|
spc = _spc(sectors, bps)
|
||||||
|
fat = 1
|
||||||
|
for _ in range(20): # converge FAT size <-> cluster count
|
||||||
|
clusters = (sectors - RESERVED - NUM_FATS * fat) // spc
|
||||||
|
need = math.ceil((clusters + 2) * 4 / bps)
|
||||||
|
if need == fat:
|
||||||
|
break
|
||||||
|
fat = need
|
||||||
|
clusters = (sectors - RESERVED - NUM_FATS * fat) // spc
|
||||||
|
if not MIN_CLUSTERS <= clusters < MAX_CLUSTERS:
|
||||||
|
raise ValueError(f"card size gives {clusters} clusters - not valid for FAT32")
|
||||||
|
return Fat32Layout(bps, PART_START, sectors, spc, fat, clusters)
|
||||||
|
|
||||||
|
|
||||||
|
def _boot_sector(lay: Fat32Layout, label: bytes, vol_id: int) -> bytes:
|
||||||
|
bps = lay.bytes_per_sector
|
||||||
|
b = bytearray(bps)
|
||||||
|
b[0:3] = b"\xEB\x58\x90"
|
||||||
|
b[3:11] = b"MSWIN4.1"
|
||||||
|
struct.pack_into("<HBHBHHBHHHII", b, 11, bps, lay.sectors_per_cluster, RESERVED, NUM_FATS, 0, 0,
|
||||||
|
MEDIA, 0, 63, 255, lay.part_start // bps, lay.part_sectors)
|
||||||
|
struct.pack_into("<IHHIHH", b, 36, lay.fat_sectors, 0, 0, 2, 1, 6)
|
||||||
|
struct.pack_into("<BBBI", b, 64, 0x80, 0, 0x29, vol_id)
|
||||||
|
b[71:82] = label
|
||||||
|
b[82:90] = b"FAT32 "
|
||||||
|
b[90:93] = b"\xF4\xEB\xFD" # not bootable: hlt loop
|
||||||
|
b[510:512] = b"\x55\xAA"
|
||||||
|
return bytes(b)
|
||||||
|
|
||||||
|
|
||||||
|
def _fsinfo(lay: Fat32Layout) -> bytes:
|
||||||
|
b = bytearray(lay.bytes_per_sector)
|
||||||
|
struct.pack_into("<I", b, 0, 0x41615252)
|
||||||
|
struct.pack_into("<III", b, 484, 0x61417272, lay.clusters - 1, 3) # root uses cluster 2
|
||||||
|
struct.pack_into("<I", b, 508, 0xAA550000)
|
||||||
|
return bytes(b)
|
||||||
|
|
||||||
|
|
||||||
|
def mbr(lay: Fat32Layout, signature: int) -> bytes:
|
||||||
|
b = bytearray(512)
|
||||||
|
struct.pack_into("<I", b, 0x1B8, signature)
|
||||||
|
entry = struct.pack("<B3sB3sII", 0x00, b"\xFE\xFF\xFF", 0x0C, b"\xFE\xFF\xFF",
|
||||||
|
lay.part_start // lay.bytes_per_sector, lay.part_sectors)
|
||||||
|
b[0x1BE:0x1BE + 16] = entry
|
||||||
|
b[510:512] = b"\x55\xAA"
|
||||||
|
return bytes(b[:512]) + bytes(lay.bytes_per_sector - 512)
|
||||||
|
|
||||||
|
|
||||||
|
def volume_label(text: str) -> bytes:
|
||||||
|
label = text.upper().encode("ascii", "replace")[:11]
|
||||||
|
return label.ljust(11, b" ")
|
||||||
|
|
||||||
|
|
||||||
|
def build(lay: Fat32Layout, label: str, vol_id: int, disk_signature: int,
|
||||||
|
disk_bytes: int) -> tuple[list[tuple[int, int]], list[tuple[int, bytes]]]:
|
||||||
|
"""(zero ranges, writes) in disk byte offsets; apply zero ranges first."""
|
||||||
|
bps, p = lay.bytes_per_sector, lay.part_start
|
||||||
|
lab = volume_label(label)
|
||||||
|
fat_bytes = lay.fat_sectors * bps
|
||||||
|
fat1 = p + RESERVED * bps
|
||||||
|
fat2 = fat1 + fat_bytes
|
||||||
|
root = p + lay.data_start * bps
|
||||||
|
cluster = lay.sectors_per_cluster * bps
|
||||||
|
zeros = [
|
||||||
|
(0, PART_START), # old MBR / GPT header and gap
|
||||||
|
(disk_bytes - PART_START, PART_START), # backup GPT at the end of the disk
|
||||||
|
(p, RESERVED * bps), # reserved region
|
||||||
|
(fat1, NUM_FATS * fat_bytes), # both FATs
|
||||||
|
(root, cluster), # root directory cluster
|
||||||
|
]
|
||||||
|
boot, info = _boot_sector(lay, lab, vol_id), _fsinfo(lay)
|
||||||
|
first_fat = struct.pack("<III", 0x0FFFFF00 | MEDIA, 0x0FFFFFFF, 0x0FFFFFFF)
|
||||||
|
label_entry = lab + bytes([0x08]) + bytes(20) # 32-byte volume-label directory entry
|
||||||
|
writes = [
|
||||||
|
(p, boot), (p + bps, info), (p + 6 * bps, boot), (p + 7 * bps, info),
|
||||||
|
(fat1, first_fat), (fat2, first_fat),
|
||||||
|
(root, label_entry),
|
||||||
|
(0, mbr(lay, disk_signature)), # last: the partition appears only now
|
||||||
|
]
|
||||||
|
return zeros, writes
|
||||||
+49
-62
@@ -3,11 +3,12 @@
|
|||||||
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 a generated PowerShell
|
Windows: formatting needs administrator rights, so kota_tech.sd.win_format
|
||||||
script runs elevated (UAC prompt). The script re-checks the disk (number,
|
runs elevated (UAC prompt). It re-checks the disk (number, size, bus, not the
|
||||||
size, bus, not system/boot) right before erasing it, so a card swapped
|
Windows drive) right before erasing it, so a card swapped after the
|
||||||
after the technician confirmed is not touched. Windows cannot format FAT32
|
technician confirmed is not touched, and it writes MBR + FAT32 itself
|
||||||
above 32 GB, so larger cards get a 32 GB partition (plenty for the box).
|
(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).
|
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -23,11 +24,10 @@ import uuid
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .disks import CARD_BUSES, 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"
|
||||||
FAT32_MAX = 32 * 1024**3
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -49,31 +49,6 @@ class CardReport:
|
|||||||
|
|
||||||
# ----------------------------------------------------------------- Windows
|
# ----------------------------------------------------------------- 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"
|
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
|
||||||
|
|
||||||
|
|
||||||
@@ -86,42 +61,54 @@ def _workdir() -> Path:
|
|||||||
return work
|
return work
|
||||||
|
|
||||||
|
|
||||||
def _query_windows_card(number: int) -> tuple[str, str, str]:
|
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
|
||||||
"""(drive, filesystem, label) of the card's first partition - no admin rights needed."""
|
"""(program, parameters, working dir) for the elevated helper."""
|
||||||
ps = (f"$p = Get-Partition -DiskNumber {number} -ErrorAction SilentlyContinue | Select-Object -First 1; "
|
root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/
|
||||||
"if ($p) { $v = $p | Get-Volume -ErrorAction SilentlyContinue; "
|
params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
|
||||||
"\"$($p.DriveLetter)|$($v.FileSystem)|$($v.FileSystemLabel)\" }")
|
f'--label {VOLUME_LABEL} --result "{result}"'
|
||||||
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True,
|
return sys.executable, params, str(root)
|
||||||
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 _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:
|
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
|
||||||
work = _workdir()
|
result = _workdir() / "result.json"
|
||||||
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)...")
|
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
|
||||||
launcher = ("$p = Start-Process powershell -Verb RunAs -Wait -PassThru -WindowStyle Hidden "
|
t = time.perf_counter()
|
||||||
f"-ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"{script}\"'; exit $p.ExitCode")
|
code = _run_elevated(*win_format_command(disk, result))
|
||||||
proc = subprocess.run(["powershell", "-NoProfile", "-Command", launcher], capture_output=True, text=True,
|
if code is None:
|
||||||
timeout=600, creationflags=subprocess.CREATE_NO_WINDOW)
|
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
|
||||||
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:
|
try:
|
||||||
res = json.loads(result.read_text(encoding="utf-8-sig"))
|
res = json.loads(result.read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
# The script ran but its report is unreadable: judge by the card itself.
|
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
|
||||||
log.append(f"Could not read the format result ({exc.__class__.__name__}); checking the card directly.")
|
"Check the card with 'Check card'.", log=log)
|
||||||
drive, fs, label = _query_windows_card(int(disk.id))
|
steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items())
|
||||||
ok = bool(drive) and fs.upper() == "FAT32" and label == VOLUME_LABEL
|
log.append(f"{res.get('message', '')} ({time.perf_counter() - t:.1f} s incl. permission prompt; {steps})")
|
||||||
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)
|
return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Fast Windows card discovery straight from the storage drivers (ctypes).
|
||||||
|
|
||||||
|
Why not Get-Disk / WMI: every fresh PowerShell/WMI query enumerates ALL
|
||||||
|
disks, which wakes sleeping hard drives - measured 1.6 to 21 s per listing
|
||||||
|
on a PC with two SATA HDDs, freezing the UI each time. Here each physical
|
||||||
|
drive is opened with access 0 (no admin needed, no I/O to the medium) and
|
||||||
|
only its cached device descriptor is read; size and volumes are queried only
|
||||||
|
for USB/SD/MMC disks. A full listing takes milliseconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import ctypes
|
||||||
|
import string
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
from .disks import Disk, Volume
|
||||||
|
|
||||||
|
IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400
|
||||||
|
IOCTL_STORAGE_GET_DEVICE_NUMBER = 0x002D1080
|
||||||
|
IOCTL_DISK_GET_DRIVE_GEOMETRY_EX = 0x000700A0
|
||||||
|
BUS_TYPES = {1: "SCSI", 2: "ATAPI", 3: "ATA", 4: "1394", 7: "USB", 8: "RAID", 10: "SAS", 11: "SATA",
|
||||||
|
12: "SD", 13: "MMC", 14: "Virtual", 15: "FileBackedVirtual", 17: "NVMe", 18: "SCM", 19: "UFS"}
|
||||||
|
SHARE_RW, OPEN_EXISTING = 0x3, 3
|
||||||
|
DRIVE_REMOVABLE, DRIVE_FIXED = 2, 3
|
||||||
|
MAX_DRIVES = 32
|
||||||
|
|
||||||
|
_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]
|
||||||
|
_k32.GetDriveTypeW.argtypes = [wintypes.LPCWSTR]
|
||||||
|
_k32.GetVolumeInformationW.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD,
|
||||||
|
ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD),
|
||||||
|
ctypes.POINTER(wintypes.DWORD), wintypes.LPWSTR, wintypes.DWORD]
|
||||||
|
_INVALID = wintypes.HANDLE(-1).value
|
||||||
|
|
||||||
|
|
||||||
|
class _Query(ctypes.Structure): # STORAGE_PROPERTY_QUERY (StorageDeviceProperty, standard)
|
||||||
|
_fields_ = [("PropertyId", wintypes.DWORD), ("QueryType", wintypes.DWORD), ("Extra", ctypes.c_ubyte * 4)]
|
||||||
|
|
||||||
|
|
||||||
|
class _DevNumber(ctypes.Structure): # STORAGE_DEVICE_NUMBER
|
||||||
|
_fields_ = [("DeviceType", wintypes.DWORD), ("DeviceNumber", wintypes.DWORD),
|
||||||
|
("PartitionNumber", wintypes.DWORD)]
|
||||||
|
|
||||||
|
|
||||||
|
def _open(path: str):
|
||||||
|
h = _k32.CreateFileW(path, 0, SHARE_RW, None, OPEN_EXISTING, 0, None)
|
||||||
|
return None if h in (None, _INVALID) else h
|
||||||
|
|
||||||
|
|
||||||
|
def _ioctl(h, code: int, inbuf=None, insize: int = 0, outsize: int = 1024) -> bytes | None:
|
||||||
|
out = ctypes.create_string_buffer(outsize)
|
||||||
|
got = wintypes.DWORD()
|
||||||
|
ok = _k32.DeviceIoControl(h, code, inbuf, insize, out, outsize, ctypes.byref(got), None)
|
||||||
|
return out.raw[:got.value] if ok else None
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptor(h) -> tuple[str, str] | None:
|
||||||
|
"""(bus, "vendor product") from STORAGE_DEVICE_DESCRIPTOR - served from the driver's cache."""
|
||||||
|
q = _Query(0, 0)
|
||||||
|
raw = _ioctl(h, IOCTL_STORAGE_QUERY_PROPERTY, ctypes.byref(q), ctypes.sizeof(q))
|
||||||
|
if not raw or len(raw) < 32:
|
||||||
|
return None
|
||||||
|
vendor_off, product_off = int.from_bytes(raw[12:16], "little"), int.from_bytes(raw[16:20], "little")
|
||||||
|
bus = int.from_bytes(raw[28:32], "little")
|
||||||
|
|
||||||
|
def text(off: int) -> str:
|
||||||
|
if not off or off >= len(raw):
|
||||||
|
return ""
|
||||||
|
return raw[off:].split(b"\0", 1)[0].decode("ascii", "replace").strip()
|
||||||
|
|
||||||
|
return BUS_TYPES.get(bus, f"bus{bus}"), " ".join(t for t in (text(vendor_off), text(product_off)) if t) or "Disk"
|
||||||
|
|
||||||
|
|
||||||
|
def _size(h) -> int:
|
||||||
|
raw = _ioctl(h, IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, outsize=256) # fails when no card is inserted
|
||||||
|
return int.from_bytes(raw[24:32], "little") if raw and len(raw) >= 32 else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _volumes_by_disk() -> dict[int, list[Volume]]:
|
||||||
|
found: dict[int, list[Volume]] = {}
|
||||||
|
mask = _k32.GetLogicalDrives()
|
||||||
|
for i, letter in enumerate(string.ascii_uppercase):
|
||||||
|
if not mask & (1 << i) or _k32.GetDriveTypeW(f"{letter}:\\") not in (DRIVE_REMOVABLE, DRIVE_FIXED):
|
||||||
|
continue
|
||||||
|
h = _open(f"\\\\.\\{letter}:")
|
||||||
|
if h is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = _ioctl(h, IOCTL_STORAGE_GET_DEVICE_NUMBER, outsize=ctypes.sizeof(_DevNumber))
|
||||||
|
finally:
|
||||||
|
_k32.CloseHandle(h)
|
||||||
|
if raw and len(raw) >= 8:
|
||||||
|
num = int.from_bytes(raw[4:8], "little")
|
||||||
|
found.setdefault(num, []).append(Volume(f"{letter}:\\", "", ""))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_volume_info(v: Volume) -> None:
|
||||||
|
name, fs = ctypes.create_unicode_buffer(64), ctypes.create_unicode_buffer(32)
|
||||||
|
serial, maxlen, flags = wintypes.DWORD(), wintypes.DWORD(), wintypes.DWORD()
|
||||||
|
if _k32.GetVolumeInformationW(v.mount, name, 64, ctypes.byref(serial), ctypes.byref(maxlen),
|
||||||
|
ctypes.byref(flags), fs, 32):
|
||||||
|
v.filesystem, v.label = fs.value, name.value
|
||||||
|
|
||||||
|
|
||||||
|
MEDIA_TIMEOUT_S = 1.5
|
||||||
|
# Slot queries still running from an earlier listing (an empty card-reader
|
||||||
|
# slot can block ~13 s on its first media query). Never start a second one.
|
||||||
|
_pending: dict[int, concurrent.futures.Future] = {}
|
||||||
|
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="sd-media")
|
||||||
|
|
||||||
|
|
||||||
|
def _media_size(n: int) -> int:
|
||||||
|
h = _open(f"\\\\.\\PhysicalDrive{n}")
|
||||||
|
if h is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return _size(h)
|
||||||
|
finally:
|
||||||
|
_k32.CloseHandle(h)
|
||||||
|
|
||||||
|
|
||||||
|
def list_disks(buses: set[str]) -> list[Disk]:
|
||||||
|
"""Disks on the given buses (e.g. {"USB", "SD", "MMC"}), with size and volumes.
|
||||||
|
|
||||||
|
Media queries run in parallel with a short timeout: a slot that does not
|
||||||
|
answer in time is reported as "no card" instead of freezing the caller.
|
||||||
|
"""
|
||||||
|
candidates: list[tuple[int, tuple[str, str]]] = []
|
||||||
|
for n in range(MAX_DRIVES):
|
||||||
|
h = _open(f"\\\\.\\PhysicalDrive{n}")
|
||||||
|
if h is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
desc = _descriptor(h)
|
||||||
|
finally:
|
||||||
|
_k32.CloseHandle(h)
|
||||||
|
if desc is not None and desc[0] in buses: # internal disks: only the cached descriptor is read
|
||||||
|
candidates.append((n, desc))
|
||||||
|
for n, _ in candidates:
|
||||||
|
if n not in _pending or _pending[n].done():
|
||||||
|
_pending[n] = _pool.submit(_media_size, n)
|
||||||
|
concurrent.futures.wait([_pending[n] for n, _ in candidates], timeout=MEDIA_TIMEOUT_S)
|
||||||
|
volumes: dict[int, list[Volume]] | None = None
|
||||||
|
disks = []
|
||||||
|
for n, (bus, name) in candidates:
|
||||||
|
fut = _pending[n]
|
||||||
|
size = fut.result() if fut.done() and fut.exception() is None else 0
|
||||||
|
if size and volumes is None:
|
||||||
|
volumes = _volumes_by_disk()
|
||||||
|
vols = (volumes or {}).get(n, []) if size else []
|
||||||
|
for v in vols:
|
||||||
|
_fill_volume_info(v)
|
||||||
|
disks.append(Disk(str(n), name, size, bus, vols, has_media=size > 0))
|
||||||
|
return disks
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Elevated Windows SD-card formatter (runs as: python -m kota_tech.sd.win_format ...).
|
||||||
|
|
||||||
|
Replaces Clear-Disk / New-Partition / Format-Volume: every Storage cmdlet
|
||||||
|
goes through the storage provider, which stalls ~13 s on an empty card
|
||||||
|
reader slot, several times per format. This helper touches only the chosen
|
||||||
|
disk through its own driver:
|
||||||
|
|
||||||
|
1. guard: bus is USB/SD/MMC, size is exactly the one the technician
|
||||||
|
confirmed, and the disk does not hold the Windows drive;
|
||||||
|
2. lock + dismount the card's volumes;
|
||||||
|
3. raw writes: zero old MBR/GPT, FAT32 structures (kota_tech.sd.fat32),
|
||||||
|
then the new MBR last;
|
||||||
|
4. unlock, let Windows re-read the disk, wait for the FAT32 'KOTA' volume;
|
||||||
|
5. read-only `chkdsk` of the new volume.
|
||||||
|
|
||||||
|
The result (with per-step timings) goes to a JSON file for the non-elevated
|
||||||
|
tool, which then writes ENV.TXT through the normal file API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
from . import fat32, win_disks
|
||||||
|
|
||||||
|
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
|
||||||
|
FSCTL_LOCK_VOLUME, FSCTL_UNLOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x0009001C, 0x00090020
|
||||||
|
IOCTL_DISK_UPDATE_PROPERTIES = 0x00070140
|
||||||
|
CARD_BUSES = {"USB", "SD", "MMC"}
|
||||||
|
CHUNK = 1024 * 1024
|
||||||
|
|
||||||
|
k32 = win_disks._k32
|
||||||
|
k32.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.DWORD,
|
||||||
|
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||||||
|
k32.SetFilePointerEx.argtypes = [wintypes.HANDLE, ctypes.c_longlong, ctypes.POINTER(ctypes.c_longlong), wintypes.DWORD]
|
||||||
|
k32.VirtualAlloc.restype = wintypes.LPVOID
|
||||||
|
k32.VirtualAlloc.argtypes = [wintypes.LPVOID, ctypes.c_size_t, wintypes.DWORD, wintypes.DWORD]
|
||||||
|
|
||||||
|
|
||||||
|
class FormatError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _open_rw(path: str):
|
||||||
|
h = k32.CreateFileW(path, GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
|
||||||
|
if h in (None, win_disks._INVALID):
|
||||||
|
raise FormatError(f"cannot open {path} (error {ctypes.get_last_error()})")
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _ioctl(h, code: int) -> bool:
|
||||||
|
got = wintypes.DWORD()
|
||||||
|
return bool(k32.DeviceIoControl(h, code, None, 0, None, 0, ctypes.byref(got), None))
|
||||||
|
|
||||||
|
|
||||||
|
class RawDisk:
|
||||||
|
"""Sector-aligned writes through a page-aligned buffer (device alignment rules)."""
|
||||||
|
|
||||||
|
def __init__(self, number: int, bps: int):
|
||||||
|
self.h = _open_rw(rf"\\.\PhysicalDrive{number}")
|
||||||
|
self.bps = bps
|
||||||
|
self.buf = k32.VirtualAlloc(None, CHUNK, 0x3000, 0x04) # MEM_COMMIT|RESERVE, RW
|
||||||
|
|
||||||
|
def write(self, offset: int, data: bytes) -> None:
|
||||||
|
if offset % self.bps or len(data) % self.bps:
|
||||||
|
raise FormatError("unaligned write")
|
||||||
|
pos = 0
|
||||||
|
while pos < len(data):
|
||||||
|
n = min(CHUNK, len(data) - pos)
|
||||||
|
ctypes.memmove(self.buf, data[pos:pos + n], n)
|
||||||
|
self._raw(offset + pos, n)
|
||||||
|
pos += n
|
||||||
|
|
||||||
|
def zero(self, offset: int, length: int) -> None:
|
||||||
|
ctypes.memset(self.buf, 0, CHUNK)
|
||||||
|
pos = 0
|
||||||
|
while pos < length:
|
||||||
|
n = min(CHUNK, length - pos)
|
||||||
|
self._raw(offset + pos, n)
|
||||||
|
pos += n
|
||||||
|
|
||||||
|
def _raw(self, offset: int, n: int) -> None:
|
||||||
|
if not k32.SetFilePointerEx(self.h, offset, None, 0):
|
||||||
|
raise FormatError(f"seek failed (error {ctypes.get_last_error()})")
|
||||||
|
done = wintypes.DWORD()
|
||||||
|
if not k32.WriteFile(self.h, self.buf, n, ctypes.byref(done), None) or done.value != n:
|
||||||
|
raise FormatError(f"write at {offset} failed (error {ctypes.get_last_error()}) - write-protected card?")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
k32.FlushFileBuffers(self.h)
|
||||||
|
_ioctl(self.h, IOCTL_DISK_UPDATE_PROPERTIES) # re-read the new partition table
|
||||||
|
k32.CloseHandle(self.h)
|
||||||
|
|
||||||
|
|
||||||
|
def format_card(number: int, expected_size: int, label: str, timings: dict) -> str:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
disk = next((d for d in win_disks.list_disks(CARD_BUSES) if d.id == str(number)), None)
|
||||||
|
if disk is None:
|
||||||
|
raise FormatError("Refusing: the disk is not a USB/SD/MMC card reader.")
|
||||||
|
if disk.size != expected_size:
|
||||||
|
raise FormatError("Refusing: the disk changed since it was selected (card swapped?).")
|
||||||
|
system = os.environ.get("SystemDrive", "C:").upper().rstrip("\\") + "\\"
|
||||||
|
if any(v.mount.upper() == system for v in disk.volumes):
|
||||||
|
raise FormatError("Refusing: this disk holds the Windows drive.")
|
||||||
|
timings["guard"] = time.perf_counter() - t0
|
||||||
|
|
||||||
|
t = time.perf_counter()
|
||||||
|
locked = []
|
||||||
|
for v in disk.volumes:
|
||||||
|
h = _open_rw(rf"\\.\{v.mount[0]}:")
|
||||||
|
for _ in range(20):
|
||||||
|
if _ioctl(h, FSCTL_LOCK_VOLUME):
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
else:
|
||||||
|
k32.CloseHandle(h)
|
||||||
|
raise FormatError(f"{v.mount} is in use by another program - close it and try again.")
|
||||||
|
_ioctl(h, FSCTL_DISMOUNT_VOLUME)
|
||||||
|
locked.append(h)
|
||||||
|
timings["lock_volumes"] = time.perf_counter() - t
|
||||||
|
|
||||||
|
t = time.perf_counter()
|
||||||
|
lay = fat32.plan(disk.size)
|
||||||
|
zeros, writes = fat32.build(lay, label, random.getrandbits(32), random.getrandbits(32), disk.size)
|
||||||
|
raw = RawDisk(number, lay.bytes_per_sector)
|
||||||
|
try:
|
||||||
|
for off, n in zeros:
|
||||||
|
raw.zero(off, n)
|
||||||
|
for off, data in writes: # MBR is last: the partition appears complete
|
||||||
|
raw.write(off, data)
|
||||||
|
finally:
|
||||||
|
for h in locked:
|
||||||
|
_ioctl(h, FSCTL_UNLOCK_VOLUME)
|
||||||
|
k32.CloseHandle(h)
|
||||||
|
raw.close()
|
||||||
|
timings["write"] = time.perf_counter() - t
|
||||||
|
|
||||||
|
t = time.perf_counter()
|
||||||
|
drive = ""
|
||||||
|
for _ in range(80): # Windows mounts the new volume by itself
|
||||||
|
d = next((x for x in win_disks.list_disks(CARD_BUSES) if x.id == str(number)), None)
|
||||||
|
vol = next((v for v in (d.volumes if d else []) if v.filesystem.upper() == "FAT32"), None)
|
||||||
|
if vol and vol.label.upper() == label.upper():
|
||||||
|
drive = vol.mount
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
timings["mount"] = time.perf_counter() - t
|
||||||
|
if not drive:
|
||||||
|
raise FormatError("Formatted, but Windows did not mount the card - remove and re-insert it.")
|
||||||
|
|
||||||
|
t = time.perf_counter()
|
||||||
|
chk = subprocess.run(["chkdsk", drive.rstrip("\\")], capture_output=True, text=True, timeout=120,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||||
|
timings["chkdsk"] = time.perf_counter() - t
|
||||||
|
if chk.returncode != 0:
|
||||||
|
tail = " ".join(chk.stdout.split()[-30:])
|
||||||
|
raise FormatError(f"chkdsk reported problems on the new file system: {tail}")
|
||||||
|
return drive
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--disk", type=int, required=True)
|
||||||
|
ap.add_argument("--size", type=int, required=True)
|
||||||
|
ap.add_argument("--label", default="KOTA")
|
||||||
|
ap.add_argument("--result", required=True)
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
timings: dict[str, float] = {}
|
||||||
|
result = {"ok": False, "message": "", "drive": "", "timings": timings}
|
||||||
|
try:
|
||||||
|
result["drive"] = format_card(a.disk, a.size, a.label, timings)
|
||||||
|
result["ok"] = True
|
||||||
|
result["message"] = "Formatted FAT32 (MBR), checked with chkdsk."
|
||||||
|
except FormatError as exc:
|
||||||
|
result["message"] = str(exc)
|
||||||
|
except Exception as exc: # report everything to the tool
|
||||||
|
result["message"] = f"Unexpected error: {exc!r}"
|
||||||
|
with open(a.result, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(result, fh)
|
||||||
|
return 0 if result["ok"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+234
-230
@@ -1,230 +1,234 @@
|
|||||||
"""Diagnostics side panel: renders a diag.engine.Report.
|
"""Diagnostics side panel: renders a diag.engine.Report.
|
||||||
|
|
||||||
Layout: verdict banner, expected LED, firmware, start-up milestones, a
|
Layout: verdict banner, expected LED, firmware, start-up milestones, a
|
||||||
findings tree (root causes; their consequences as children; titles only)
|
findings tree (root causes; their consequences as children; titles only)
|
||||||
and a wrapped detail pane for the selected finding.
|
and a wrapped detail pane for the selected finding.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtGui import QBrush, QColor
|
from PySide6.QtGui import QBrush, QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QSizePolicy,
|
QPushButton,
|
||||||
QSplitter,
|
QSizePolicy,
|
||||||
QTextBrowser,
|
QSplitter,
|
||||||
QTreeWidget,
|
QTextBrowser,
|
||||||
QTreeWidgetItem,
|
QTreeWidget,
|
||||||
QVBoxLayout,
|
QTreeWidgetItem,
|
||||||
QWidget,
|
QVBoxLayout,
|
||||||
)
|
QWidget,
|
||||||
|
)
|
||||||
from ..diag.engine import LED, Finding, Report, Severity
|
|
||||||
|
from ..diag.engine import LED, Finding, Report, Severity
|
||||||
SEV_COLOR = {
|
|
||||||
Severity.CRITICAL: "#b03a2e",
|
SEV_COLOR = {
|
||||||
Severity.ERROR: "#d9534f",
|
Severity.CRITICAL: "#b03a2e",
|
||||||
Severity.WARNING: "#c77c02",
|
Severity.ERROR: "#d9534f",
|
||||||
Severity.INFO: "#2e6da4",
|
Severity.WARNING: "#c77c02",
|
||||||
}
|
Severity.INFO: "#2e6da4",
|
||||||
# verdict banner (background, text) by verdict severity; None = healthy
|
}
|
||||||
BANNER = {
|
# verdict banner (background, text) by verdict severity; None = healthy
|
||||||
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
|
BANNER = {
|
||||||
Severity.ERROR: ("#f9dedc", "#8a2019"),
|
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
|
||||||
Severity.WARNING: ("#fdebc8", "#6b4400"),
|
Severity.ERROR: ("#f9dedc", "#8a2019"),
|
||||||
None: ("#d8f0dd", "#1b5e2a"),
|
Severity.WARNING: ("#fdebc8", "#6b4400"),
|
||||||
}
|
None: ("#d8f0dd", "#1b5e2a"),
|
||||||
_KEY = Qt.ItemDataRole.UserRole
|
}
|
||||||
|
_KEY = Qt.ItemDataRole.UserRole
|
||||||
|
|
||||||
def _heading(text: str) -> QLabel:
|
|
||||||
lbl = QLabel(text)
|
def _heading(text: str) -> QLabel:
|
||||||
f = lbl.font()
|
lbl = QLabel(text)
|
||||||
f.setBold(True)
|
f = lbl.font()
|
||||||
lbl.setFont(f)
|
f.setBold(True)
|
||||||
return lbl
|
lbl.setFont(f)
|
||||||
|
return lbl
|
||||||
|
|
||||||
def _esc(s: str) -> str:
|
|
||||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
def _esc(s: str) -> str:
|
||||||
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
class DiagnosticsPanel(QWidget):
|
|
||||||
def __init__(self, parent: QWidget | None = None):
|
class DiagnosticsPanel(QWidget):
|
||||||
super().__init__(parent)
|
def __init__(self, parent: QWidget | None = None):
|
||||||
self.setMinimumWidth(380)
|
super().__init__(parent)
|
||||||
|
self.setMinimumWidth(380)
|
||||||
self.verdict = QLabel(wordWrap=True)
|
|
||||||
self.verdict.setMargin(10)
|
self.verdict = QLabel(wordWrap=True)
|
||||||
vf = self.verdict.font()
|
self.verdict.setMargin(10)
|
||||||
vf.setPointSize(vf.pointSize() + 2)
|
vf = self.verdict.font()
|
||||||
vf.setBold(True)
|
vf.setPointSize(vf.pointSize() + 2)
|
||||||
self.verdict.setFont(vf)
|
vf.setBold(True)
|
||||||
|
self.verdict.setFont(vf)
|
||||||
self.led_swatch = QLabel()
|
|
||||||
self.led_swatch.setFixedSize(22, 22)
|
self.led_swatch = QLabel()
|
||||||
self.led_text = QLabel(wordWrap=True)
|
self.led_swatch.setFixedSize(22, 22)
|
||||||
led_row = QHBoxLayout()
|
self.led_text = QLabel(wordWrap=True)
|
||||||
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
|
led_row = QHBoxLayout()
|
||||||
led_row.addWidget(self.led_text, 1)
|
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
|
led_row.addWidget(self.led_text, 1)
|
||||||
self.firmware = QLabel(wordWrap=True)
|
|
||||||
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
self.firmware = QLabel(wordWrap=True)
|
||||||
|
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
self.milestones = QListWidget()
|
|
||||||
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
|
self.milestones = QListWidget()
|
||||||
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||||
|
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||||
self.tree = QTreeWidget()
|
|
||||||
self.tree.setHeaderHidden(True)
|
self.tree = QTreeWidget()
|
||||||
self.tree.setIndentation(16)
|
self.tree.setHeaderHidden(True)
|
||||||
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
|
self.tree.setIndentation(16)
|
||||||
self.detail = QTextBrowser()
|
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||||
self.detail.setOpenExternalLinks(False)
|
self.detail = QTextBrowser()
|
||||||
findings = QSplitter(Qt.Orientation.Vertical)
|
self.detail.setOpenExternalLinks(False)
|
||||||
findings.addWidget(self.tree)
|
findings = QSplitter(Qt.Orientation.Vertical)
|
||||||
findings.addWidget(self.detail)
|
findings.addWidget(self.tree)
|
||||||
findings.setSizes([220, 260])
|
findings.addWidget(self.detail)
|
||||||
|
findings.setSizes([220, 260])
|
||||||
lay = QVBoxLayout(self)
|
|
||||||
lay.setContentsMargins(4, 0, 0, 0)
|
lay = QVBoxLayout(self)
|
||||||
lay.addWidget(self.verdict)
|
lay.setContentsMargins(4, 0, 0, 0)
|
||||||
lay.addWidget(_heading("Status LED should be"))
|
lay.addWidget(self.verdict)
|
||||||
lay.addLayout(led_row)
|
lay.addWidget(_heading("Status LED should be"))
|
||||||
lay.addWidget(_heading("Firmware"))
|
lay.addLayout(led_row)
|
||||||
lay.addWidget(self.firmware)
|
lay.addWidget(_heading("Firmware"))
|
||||||
lay.addWidget(_heading("Start-up progress (current boot)"))
|
lay.addWidget(self.firmware)
|
||||||
lay.addWidget(self.milestones)
|
self.qr_btn = QPushButton("Matter pairing QR code...")
|
||||||
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
|
||||||
lay.addWidget(findings, 1)
|
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||||||
|
lay.addWidget(_heading("Start-up progress (current boot)"))
|
||||||
self._findings: dict[str, Finding] = {}
|
lay.addWidget(self.milestones)
|
||||||
self._selected: str | None = None
|
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
||||||
# key -> expanded, chosen by the user; survives the rebuild on refresh.
|
lay.addWidget(findings, 1)
|
||||||
self._expanded: dict[str, bool] = {}
|
|
||||||
self.tree.currentItemChanged.connect(self._on_select)
|
self._findings: dict[str, Finding] = {}
|
||||||
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
|
self._selected: str | None = None
|
||||||
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
|
# key -> expanded, chosen by the user; survives the rebuild on refresh.
|
||||||
|
self._expanded: dict[str, bool] = {}
|
||||||
# ------------------------------------------------------------------
|
self.tree.currentItemChanged.connect(self._on_select)
|
||||||
def show_report(self, r: Report) -> None:
|
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
|
||||||
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
|
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
|
||||||
if r.lines == 0:
|
|
||||||
bg, fg = "#e6e6e6", "#333333"
|
# ------------------------------------------------------------------
|
||||||
self.verdict.setText(r.verdict)
|
def show_report(self, r: Report) -> None:
|
||||||
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
|
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
|
||||||
|
if r.lines == 0:
|
||||||
name, colour = LED[r.led]
|
bg, fg = "#e6e6e6", "#333333"
|
||||||
self.led_swatch.setStyleSheet(
|
self.verdict.setText(r.verdict)
|
||||||
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
|
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
|
||||||
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
|
|
||||||
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
name, colour = LED[r.led]
|
||||||
self.led_text.setText(f"<b>{name}</b>{ask}{reason}")
|
self.led_swatch.setStyleSheet(
|
||||||
|
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
|
||||||
fw = r.firmware
|
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
|
||||||
sd = {True: "reads Wi-Fi settings from the SD card",
|
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
||||||
False: "does not read Wi-Fi settings from the SD card",
|
self.led_text.setText(f"<b>{name}</b>{ask}{reason}")
|
||||||
None: "Wi-Fi-from-SD support unknown"}
|
|
||||||
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
fw = r.firmware
|
||||||
|
sd = {True: "reads Wi-Fi settings from the SD card",
|
||||||
self.milestones.clear()
|
False: "does not read Wi-Fi settings from the SD card",
|
||||||
for _, label, t in r.milestones:
|
None: "Wi-Fi-from-SD support unknown"}
|
||||||
done = t is not None
|
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
||||||
it = QListWidgetItem(("✔ " if done else "○ ") + label
|
|
||||||
+ (f" ({t / 1000:.1f} s)" if done else ""))
|
self.milestones.clear()
|
||||||
it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a")))
|
for _, label, t in r.milestones:
|
||||||
self.milestones.addItem(it)
|
done = t is not None
|
||||||
rows = min(max(self.milestones.count(), 1), 15)
|
it = QListWidgetItem(("✔ " if done else "○ ") + label
|
||||||
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
|
+ (f" ({t / 1000:.1f} s)" if done else ""))
|
||||||
|
it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a")))
|
||||||
self._fill_tree(r)
|
self.milestones.addItem(it)
|
||||||
|
rows = min(max(self.milestones.count(), 1), 15)
|
||||||
def _fill_tree(self, r: Report) -> None:
|
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
|
||||||
self.tree.blockSignals(True)
|
|
||||||
self.tree.clear()
|
self._fill_tree(r)
|
||||||
self._findings = {}
|
|
||||||
if not r.roots:
|
def _fill_tree(self, r: Report) -> None:
|
||||||
it = QTreeWidgetItem(["No problems detected in the log so far."])
|
self.tree.blockSignals(True)
|
||||||
it.setForeground(0, QBrush(QColor("#1b7a33")))
|
self.tree.clear()
|
||||||
self.tree.addTopLevelItem(it)
|
self._findings = {}
|
||||||
for rc in r.roots:
|
if not r.roots:
|
||||||
top = self._item(rc.finding, bold=True)
|
it = QTreeWidgetItem(["No problems detected in the log so far."])
|
||||||
self.tree.addTopLevelItem(top)
|
it.setForeground(0, QBrush(QColor("#1b7a33")))
|
||||||
for c in rc.consequences:
|
self.tree.addTopLevelItem(it)
|
||||||
top.addChild(self._item(c, prefix="↳ "))
|
for rc in r.roots:
|
||||||
top.setExpanded(self._expanded.get(rc.finding.key, True))
|
top = self._item(rc.finding, bold=True)
|
||||||
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
|
self.tree.addTopLevelItem(top)
|
||||||
("Resolved during this session", "grp:resolved", r.resolved)):
|
for c in rc.consequences:
|
||||||
if not items:
|
top.addChild(self._item(c, prefix="↳ "))
|
||||||
continue
|
top.setExpanded(self._expanded.get(rc.finding.key, True))
|
||||||
grp = QTreeWidgetItem([f"{title} ({len(items)})"])
|
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
|
||||||
grp.setData(0, _KEY, key)
|
("Resolved during this session", "grp:resolved", r.resolved)):
|
||||||
grp.setForeground(0, QBrush(QColor("#666666")))
|
if not items:
|
||||||
self.tree.addTopLevelItem(grp)
|
continue
|
||||||
for f in items:
|
grp = QTreeWidgetItem([f"{title} ({len(items)})"])
|
||||||
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
|
grp.setData(0, _KEY, key)
|
||||||
grp.setExpanded(self._expanded.get(key, False))
|
grp.setForeground(0, QBrush(QColor("#666666")))
|
||||||
# Keep the selection across refreshes; default to the top root cause.
|
self.tree.addTopLevelItem(grp)
|
||||||
target = self._selected if self._selected in self._findings else (
|
for f in items:
|
||||||
r.roots[0].finding.key if r.roots else None)
|
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
|
||||||
self.tree.blockSignals(False)
|
grp.setExpanded(self._expanded.get(key, False))
|
||||||
self._select(target)
|
# Keep the selection across refreshes; default to the top root cause.
|
||||||
|
target = self._selected if self._selected in self._findings else (
|
||||||
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
|
r.roots[0].finding.key if r.roots else None)
|
||||||
if key := item.data(0, _KEY):
|
self.tree.blockSignals(False)
|
||||||
self._expanded[key] = expanded
|
self._select(target)
|
||||||
|
|
||||||
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
|
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
|
||||||
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
|
if key := item.data(0, _KEY):
|
||||||
it.setData(0, _KEY, f.key)
|
self._expanded[key] = expanded
|
||||||
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
|
|
||||||
it.setToolTip(0, f.title)
|
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
|
||||||
if bold:
|
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
|
||||||
font = it.font(0)
|
it.setData(0, _KEY, f.key)
|
||||||
font.setBold(True)
|
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
|
||||||
it.setFont(0, font)
|
it.setToolTip(0, f.title)
|
||||||
self._findings[f.key] = f
|
if bold:
|
||||||
return it
|
font = it.font(0)
|
||||||
|
font.setBold(True)
|
||||||
def _select(self, key: str | None) -> None:
|
it.setFont(0, font)
|
||||||
if key is None:
|
self._findings[f.key] = f
|
||||||
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
|
return it
|
||||||
return
|
|
||||||
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
|
def _select(self, key: str | None) -> None:
|
||||||
for it in match:
|
if key is None:
|
||||||
if it.data(0, _KEY) == key:
|
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
|
||||||
self.tree.setCurrentItem(it)
|
return
|
||||||
return
|
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
|
||||||
|
for it in match:
|
||||||
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
|
if it.data(0, _KEY) == key:
|
||||||
key = cur.data(0, _KEY) if cur else None
|
self.tree.setCurrentItem(it)
|
||||||
f = self._findings.get(key or "")
|
return
|
||||||
if f is None:
|
|
||||||
return
|
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
|
||||||
self._selected = key
|
key = cur.data(0, _KEY) if cur else None
|
||||||
self.detail.setHtml(_detail_html(f))
|
f = self._findings.get(key or "")
|
||||||
|
if f is None:
|
||||||
|
return
|
||||||
def _detail_html(f: Finding) -> str:
|
self._selected = key
|
||||||
colour = SEV_COLOR.get(f.severity, "#333")
|
self.detail.setHtml(_detail_html(f))
|
||||||
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
|
||||||
seen = f"Seen {f.count}x"
|
|
||||||
if f.first_uptime_ms is not None:
|
def _detail_html(f: Finding) -> str:
|
||||||
seen += f", first at {f.first_uptime_ms / 1000:.1f} s after start"
|
colour = SEV_COLOR.get(f.severity, "#333")
|
||||||
if f.resolved and f.resolved_by:
|
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
||||||
seen += f" - resolved ({_esc(f.resolved_by)})"
|
seen = f"Seen {f.count}x"
|
||||||
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
if f.first_uptime_ms is not None:
|
||||||
return (
|
seen += f", first at {f.first_uptime_ms / 1000:.1f} s after start"
|
||||||
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
|
if f.resolved and f.resolved_by:
|
||||||
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
|
seen += f" - resolved ({_esc(f.resolved_by)})"
|
||||||
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
|
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
||||||
+ f"<p style='color:#666'>{seen}</p>"
|
return (
|
||||||
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
|
||||||
if evidence else "")
|
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
|
||||||
)
|
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
|
||||||
|
+ f"<p style='color:#666'>{seen}</p>"
|
||||||
|
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||||||
|
if evidence else "")
|
||||||
|
)
|
||||||
@@ -11,6 +11,7 @@ from PySide6.QtWidgets import (
|
|||||||
QButtonGroup,
|
QButtonGroup,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
QInputDialog,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -25,7 +26,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..core.session import SESSIONS_DIR
|
from ..core.session import SESSIONS_DIR
|
||||||
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, factory_images
|
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, bundled_images
|
||||||
from ..flash.plan import Mode, build_plan
|
from ..flash.plan import Mode, build_plan
|
||||||
from ..flash.runner import FlashRunner, Result
|
from ..flash.runner import FlashRunner, Result
|
||||||
from ..serialio.ports import PortInfo, list_serial_ports
|
from ..serialio.ports import PortInfo, list_serial_ports
|
||||||
@@ -168,12 +169,14 @@ class FlashTab(QWidget):
|
|||||||
def _load_images(self, select_sha: str | None = None) -> None:
|
def _load_images(self, select_sha: str | None = None) -> None:
|
||||||
self._problems = []
|
self._problems = []
|
||||||
try:
|
try:
|
||||||
factory = factory_images()
|
bundled = bundled_images()
|
||||||
except ImageError as exc:
|
except ImageError as exc:
|
||||||
factory = []
|
bundled = []
|
||||||
self._problems.append(f"factory image: {exc}")
|
self._problems.append(f"bundled image: {exc}")
|
||||||
|
factory = [i for i in bundled if not i.is_developer]
|
||||||
self._reference = factory[0] if factory else None
|
self._reference = factory[0] if factory else None
|
||||||
dev, problems = self._library.images(self._reference)
|
dev, problems = self._library.images(self._reference)
|
||||||
|
dev += [i for i in bundled if i.is_developer]
|
||||||
self._problems += problems
|
self._problems += problems
|
||||||
# Newest developer builds first, then the factory image.
|
# Newest developer builds first, then the factory image.
|
||||||
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True)
|
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True)
|
||||||
@@ -196,8 +199,12 @@ class FlashTab(QWidget):
|
|||||||
"Firmware (*.bin);;All files (*)")
|
"Firmware (*.bin);;All files (*)")
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
|
built_by, ok = QInputDialog.getText(self, "Who built this firmware?",
|
||||||
|
"Name of the developer who built this image (shown next to it):")
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
img = self._library.import_file(Path(path), self._reference)
|
img = self._library.import_file(Path(path), self._reference, built_by=built_by)
|
||||||
except (ImageError, OSError) as exc:
|
except (ImageError, OSError) as exc:
|
||||||
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}")
|
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}")
|
||||||
return
|
return
|
||||||
|
|||||||
+225
-216
@@ -1,216 +1,225 @@
|
|||||||
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import QSize, Qt
|
from PySide6.QtCore import QSize, Qt
|
||||||
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QProgressBar,
|
QProgressBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..diag.engine import LED, DiagnosticsEngine
|
from ..diag.engine import LED, DiagnosticsEngine
|
||||||
from ..diag.guides import GuideRunner, recommended
|
from ..diag.guides import GuideRunner, recommended
|
||||||
|
from ..matter.payload import SetupPayload
|
||||||
END_STYLE = { # end severity -> (border, background)
|
from .matter_qr import MatterQrWidget
|
||||||
"ok": ("#2e9e44", "#e3f4e7"),
|
|
||||||
"info": ("#2e6da4", "#e3edf7"),
|
END_STYLE = { # end severity -> (border, background)
|
||||||
"warning": ("#c77c02", "#fdf0d6"),
|
"ok": ("#2e9e44", "#e3f4e7"),
|
||||||
"error": ("#d9534f", "#f9e0df"),
|
"info": ("#2e6da4", "#e3edf7"),
|
||||||
"critical": ("#b03a2e", "#f6d5d1"),
|
"warning": ("#c77c02", "#fdf0d6"),
|
||||||
}
|
"error": ("#d9534f", "#f9e0df"),
|
||||||
|
"critical": ("#b03a2e", "#f6d5d1"),
|
||||||
|
}
|
||||||
def _swatch(key: str, size: int = 22) -> QIcon:
|
|
||||||
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
|
|
||||||
pm = QPixmap(size, size)
|
def _swatch(key: str, size: int = 22) -> QIcon:
|
||||||
pm.fill(QColor(0, 0, 0, 0))
|
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
|
||||||
p = QPainter(pm)
|
pm = QPixmap(size, size)
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
pm.fill(QColor(0, 0, 0, 0))
|
||||||
if key == "CYCLING":
|
p = QPainter(pm)
|
||||||
g = QLinearGradient(0, 0, size, 0)
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
if key == "CYCLING":
|
||||||
g.setColorAt(stop, QColor(c))
|
g = QLinearGradient(0, 0, size, 0)
|
||||||
p.setBrush(QBrush(g))
|
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
||||||
else:
|
g.setColorAt(stop, QColor(c))
|
||||||
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
p.setBrush(QBrush(g))
|
||||||
p.setPen(QColor("#777777"))
|
else:
|
||||||
p.drawEllipse(1, 1, size - 2, size - 2)
|
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
||||||
p.end()
|
p.setPen(QColor("#777777"))
|
||||||
return QIcon(pm)
|
p.drawEllipse(1, 1, size - 2, size - 2)
|
||||||
|
p.end()
|
||||||
|
return QIcon(pm)
|
||||||
def _esc(s: str) -> str:
|
|
||||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
||||||
|
def _esc(s: str) -> str:
|
||||||
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
class GuidePanel(QWidget):
|
|
||||||
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None):
|
|
||||||
super().__init__(parent)
|
class GuidePanel(QWidget):
|
||||||
self.runner, self.engine = runner, engine
|
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None,
|
||||||
self._shown_key: tuple | None = None
|
matter_payload=None):
|
||||||
|
super().__init__(parent)
|
||||||
# --- picker (no guide running)
|
self.runner, self.engine = runner, engine
|
||||||
self.picker = QWidget()
|
self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
|
||||||
pl = QVBoxLayout(self.picker)
|
self._shown_key: tuple | None = None
|
||||||
pl.setContentsMargins(0, 0, 0, 0)
|
|
||||||
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
# --- picker (no guide running)
|
||||||
self.guide_list = QListWidget()
|
self.picker = QWidget()
|
||||||
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
pl = QVBoxLayout(self.picker)
|
||||||
pl.addWidget(self.guide_list, 1)
|
pl.setContentsMargins(0, 0, 0, 0)
|
||||||
self.start_btn = QPushButton("Start selected check")
|
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
||||||
self.start_btn.clicked.connect(self._start_selected)
|
self.guide_list = QListWidget()
|
||||||
pl.addWidget(self.start_btn)
|
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
||||||
|
pl.addWidget(self.guide_list, 1)
|
||||||
# --- running guide
|
self.start_btn = QPushButton("Start selected check")
|
||||||
self.card = QFrame()
|
self.start_btn.clicked.connect(self._start_selected)
|
||||||
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
pl.addWidget(self.start_btn)
|
||||||
cl = QVBoxLayout(self.card)
|
|
||||||
self.guide_title = QLabel()
|
# --- running guide
|
||||||
self.guide_title.setStyleSheet("color:#666")
|
self.card = QFrame()
|
||||||
self.step_text = QLabel(wordWrap=True)
|
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
||||||
f = self.step_text.font()
|
cl = QVBoxLayout(self.card)
|
||||||
f.setPointSize(f.pointSize() + 2)
|
self.guide_title = QLabel()
|
||||||
self.step_text.setFont(f)
|
self.guide_title.setStyleSheet("color:#666")
|
||||||
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
self.step_text = QLabel(wordWrap=True)
|
||||||
self.options = QVBoxLayout()
|
f = self.step_text.font()
|
||||||
self.wait_bar = QProgressBar()
|
f.setPointSize(f.pointSize() + 2)
|
||||||
self.wait_bar.setTextVisible(False)
|
self.step_text.setFont(f)
|
||||||
self.wait_label = QLabel()
|
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
||||||
self.note = QLabel(wordWrap=True)
|
self.options = QVBoxLayout()
|
||||||
self.note.setStyleSheet("color:#b03a2e")
|
self.wait_bar = QProgressBar()
|
||||||
self.continue_btn = QPushButton("Continue")
|
self.wait_bar.setTextVisible(False)
|
||||||
self.continue_btn.clicked.connect(self._proceed)
|
self.wait_label = QLabel()
|
||||||
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
self.note = QLabel(wordWrap=True)
|
||||||
self.skip_btn.clicked.connect(self._proceed)
|
self.note.setStyleSheet("color:#b03a2e")
|
||||||
self.end_box = QLabel(wordWrap=True)
|
self.continue_btn = QPushButton("Continue")
|
||||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
self.continue_btn.clicked.connect(self._proceed)
|
||||||
self.end_box.setMargin(10)
|
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
||||||
for w in (self.guide_title, self.step_text):
|
self.skip_btn.clicked.connect(self._proceed)
|
||||||
cl.addWidget(w)
|
self.end_box = QLabel(wordWrap=True)
|
||||||
cl.addLayout(self.options)
|
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||||
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
self.end_box.setMargin(10)
|
||||||
cl.addWidget(w)
|
self.qr = MatterQrWidget(SetupPayload(), 220)
|
||||||
cl.addStretch(1)
|
for w in (self.guide_title, self.step_text, self.qr):
|
||||||
nav = QHBoxLayout()
|
cl.addWidget(w)
|
||||||
self.back_btn = QPushButton("← Back")
|
cl.addLayout(self.options)
|
||||||
self.back_btn.clicked.connect(self._back)
|
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
||||||
self.done_btn = QPushButton("Back to checks list")
|
cl.addWidget(w)
|
||||||
self.done_btn.clicked.connect(self._cancel)
|
cl.addStretch(1)
|
||||||
nav.addWidget(self.back_btn)
|
nav = QHBoxLayout()
|
||||||
nav.addStretch(1)
|
self.back_btn = QPushButton("← Back")
|
||||||
nav.addWidget(self.done_btn)
|
self.back_btn.clicked.connect(self._back)
|
||||||
cl.addLayout(nav)
|
self.done_btn = QPushButton("Back to checks list")
|
||||||
|
self.done_btn.clicked.connect(self._cancel)
|
||||||
lay = QVBoxLayout(self)
|
nav.addWidget(self.back_btn)
|
||||||
lay.setContentsMargins(4, 0, 0, 0)
|
nav.addStretch(1)
|
||||||
lay.addWidget(self.picker, 1)
|
nav.addWidget(self.done_btn)
|
||||||
lay.addWidget(self.card, 1)
|
cl.addLayout(nav)
|
||||||
self.refresh()
|
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
# ------------------------------------------------------------ actions
|
lay.setContentsMargins(4, 0, 0, 0)
|
||||||
def _start_selected(self) -> None:
|
lay.addWidget(self.picker, 1)
|
||||||
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
lay.addWidget(self.card, 1)
|
||||||
if it:
|
self.refresh()
|
||||||
self._start(it.data(Qt.ItemDataRole.UserRole))
|
|
||||||
|
# ------------------------------------------------------------ actions
|
||||||
def _start(self, guide_id: str) -> None:
|
def _start_selected(self) -> None:
|
||||||
self.runner.start(guide_id)
|
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
||||||
self.refresh()
|
if it:
|
||||||
|
self._start(it.data(Qt.ItemDataRole.UserRole))
|
||||||
def _answer(self, key: str) -> None:
|
|
||||||
self.runner.answer(key)
|
def _start(self, guide_id: str) -> None:
|
||||||
self.refresh()
|
self.runner.start(guide_id)
|
||||||
|
self.refresh()
|
||||||
def _proceed(self) -> None:
|
|
||||||
self.runner.proceed()
|
def _answer(self, key: str) -> None:
|
||||||
self.refresh()
|
self.runner.answer(key)
|
||||||
|
self.refresh()
|
||||||
def _back(self) -> None:
|
|
||||||
self.runner.back()
|
def _proceed(self) -> None:
|
||||||
self.refresh()
|
self.runner.proceed()
|
||||||
|
self.refresh()
|
||||||
def _cancel(self) -> None:
|
|
||||||
self.runner.cancel()
|
def _back(self) -> None:
|
||||||
self.refresh()
|
self.runner.back()
|
||||||
|
self.refresh()
|
||||||
# ------------------------------------------------------------- render
|
|
||||||
def refresh(self) -> None:
|
def _cancel(self) -> None:
|
||||||
"""Called after every interaction and periodically (timeouts, countdown)."""
|
self.runner.cancel()
|
||||||
self.runner.tick()
|
self.refresh()
|
||||||
v = self.runner.view()
|
|
||||||
running = v.guide is not None
|
# ------------------------------------------------------------- render
|
||||||
self.picker.setVisible(not running)
|
def refresh(self) -> None:
|
||||||
self.card.setVisible(running)
|
"""Called after every interaction and periodically (timeouts, countdown)."""
|
||||||
if not running:
|
self.runner.tick()
|
||||||
self._fill_picker()
|
v = self.runner.view()
|
||||||
self._shown_key = None
|
running = v.guide is not None
|
||||||
return
|
self.picker.setVisible(not running)
|
||||||
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
self.card.setVisible(running)
|
||||||
if key != self._shown_key:
|
if not running:
|
||||||
self._shown_key = key
|
self._fill_picker()
|
||||||
self._render_step(v)
|
self._shown_key = None
|
||||||
if v.waiting:
|
return
|
||||||
total = v.step.timeout_s
|
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
||||||
self.wait_bar.setMaximum(int(total))
|
if key != self._shown_key:
|
||||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
self._shown_key = key
|
||||||
m, s = divmod(int(v.remaining_s), 60)
|
self._render_step(v)
|
||||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
if v.waiting:
|
||||||
self.note.setText(v.note)
|
total = v.step.timeout_s
|
||||||
self.note.setVisible(bool(v.note))
|
self.wait_bar.setMaximum(int(total))
|
||||||
self.back_btn.setEnabled(v.can_back)
|
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||||
|
m, s = divmod(int(v.remaining_s), 60)
|
||||||
def _fill_picker(self) -> None:
|
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
||||||
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
self.note.setText(v.note)
|
||||||
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
self.note.setVisible(bool(v.note))
|
||||||
if [w[0] for w in wanted] == current:
|
self.back_btn.setEnabled(v.can_back)
|
||||||
return
|
|
||||||
self.guide_list.clear()
|
def _fill_picker(self) -> None:
|
||||||
for gid, title, summary in wanted:
|
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
||||||
it = QListWidgetItem(f"{title}\n {summary}")
|
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
||||||
it.setData(Qt.ItemDataRole.UserRole, gid)
|
if [w[0] for w in wanted] == current:
|
||||||
self.guide_list.addItem(it)
|
return
|
||||||
self.guide_list.setCurrentRow(0)
|
self.guide_list.clear()
|
||||||
|
for gid, title, summary in wanted:
|
||||||
def _render_step(self, v) -> None:
|
it = QListWidgetItem(f"{title}\n {summary}")
|
||||||
self.guide_title.setText(v.guide.title)
|
it.setData(Qt.ItemDataRole.UserRole, gid)
|
||||||
while self.options.count():
|
self.guide_list.addItem(it)
|
||||||
w = self.options.takeAt(0).widget()
|
self.guide_list.setCurrentRow(0)
|
||||||
if w:
|
|
||||||
# Detach now; deleteLater alone leaves it visible until the
|
def _render_step(self, v) -> None:
|
||||||
# event loop processes the deferred delete.
|
self.guide_title.setText(v.guide.title)
|
||||||
w.hide()
|
while self.options.count():
|
||||||
w.setParent(None)
|
w = self.options.takeAt(0).widget()
|
||||||
w.deleteLater()
|
if w:
|
||||||
is_end = v.end is not None
|
# Detach now; deleteLater alone leaves it visible until the
|
||||||
self.step_text.setVisible(not is_end)
|
# event loop processes the deferred delete.
|
||||||
self.step_text.setText(_esc(v.text))
|
w.hide()
|
||||||
for opt_key, label in v.options:
|
w.setParent(None)
|
||||||
b = QPushButton(label)
|
w.deleteLater()
|
||||||
b.setMinimumHeight(34)
|
is_end = v.end is not None
|
||||||
if v.step and v.step.type == "led":
|
self.step_text.setVisible(not is_end)
|
||||||
b.setIcon(_swatch(opt_key))
|
self.step_text.setText(_esc(v.text))
|
||||||
b.setIconSize(QSize(22, 22))
|
show_qr = bool(v.step and v.step.show_qr)
|
||||||
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
if show_qr:
|
||||||
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
self.qr.set_payload(self._matter_payload())
|
||||||
self.options.addWidget(b)
|
self.qr.setVisible(show_qr)
|
||||||
step_type = v.step.type if v.step else None
|
for opt_key, label in v.options:
|
||||||
self.continue_btn.setVisible(step_type == "info")
|
b = QPushButton(label)
|
||||||
self.skip_btn.setVisible(step_type == "wait")
|
b.setMinimumHeight(34)
|
||||||
self.wait_bar.setVisible(step_type == "wait")
|
if v.step and v.step.type == "led":
|
||||||
self.wait_label.setVisible(step_type == "wait")
|
b.setIcon(_swatch(opt_key))
|
||||||
self.end_box.setVisible(is_end)
|
b.setIconSize(QSize(22, 22))
|
||||||
if is_end:
|
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
||||||
e = v.end
|
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
||||||
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
|
self.options.addWidget(b)
|
||||||
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
|
step_type = v.step.type if v.step else None
|
||||||
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
self.continue_btn.setVisible(step_type == "info")
|
||||||
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
|
self.skip_btn.setVisible(step_type == "wait")
|
||||||
+ (f"<ol>{acts}</ol>" if acts else ""))
|
self.wait_bar.setVisible(step_type == "wait")
|
||||||
|
self.wait_label.setVisible(step_type == "wait")
|
||||||
|
self.end_box.setVisible(is_end)
|
||||||
|
if is_end:
|
||||||
|
e = v.end
|
||||||
|
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
|
||||||
|
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
|
||||||
|
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
||||||
|
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
|
||||||
|
+ (f"<ol>{acts}</ol>" if acts else ""))
|
||||||
@@ -30,10 +30,12 @@ from ..diag.engine import DiagnosticsEngine
|
|||||||
from ..diag.guides import GuideRunner
|
from ..diag.guides import GuideRunner
|
||||||
from ..serialio.manager import SerialManager, State
|
from ..serialio.manager import SerialManager, State
|
||||||
from ..serialio.ports import PortInfo, list_serial_ports
|
from ..serialio.ports import PortInfo, list_serial_ports
|
||||||
|
from ..matter.payload import SetupPayload, payload_from_log
|
||||||
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
||||||
from .cheatsheet import cheatsheet_html
|
from .cheatsheet import cheatsheet_html
|
||||||
from .diag_panel import DiagnosticsPanel
|
from .diag_panel import DiagnosticsPanel
|
||||||
from .flash_tab import FlashTab
|
from .flash_tab import FlashTab
|
||||||
|
from .matter_qr import MatterQrDialog
|
||||||
from .guide_panel import GuidePanel
|
from .guide_panel import GuidePanel
|
||||||
from .sd_tab import SdTab
|
from .sd_tab import SdTab
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ class SerialTab(QWidget):
|
|||||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||||
|
|
||||||
self.diag = DiagnosticsPanel()
|
self.diag = DiagnosticsPanel()
|
||||||
self.guide = GuidePanel(self._runner, self._engine)
|
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
|
||||||
self.side_tabs = QTabWidget()
|
self.side_tabs = QTabWidget()
|
||||||
self.side_tabs.addTab(self.diag, "Diagnosis")
|
self.side_tabs.addTab(self.diag, "Diagnosis")
|
||||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||||
@@ -147,6 +149,7 @@ class SerialTab(QWidget):
|
|||||||
self.analyze_btn.clicked.connect(self._analyze_file)
|
self.analyze_btn.clicked.connect(self._analyze_file)
|
||||||
self.copy_btn.clicked.connect(self._copy_report)
|
self.copy_btn.clicked.connect(self._copy_report)
|
||||||
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
|
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
|
||||||
|
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
|
||||||
|
|
||||||
# Report refresh is throttled; lines can arrive at >100/s.
|
# Report refresh is throttled; lines can arrive at >100/s.
|
||||||
self._diag_timer = QTimer(self, interval=400)
|
self._diag_timer = QTimer(self, interval=400)
|
||||||
@@ -205,6 +208,10 @@ class SerialTab(QWidget):
|
|||||||
self._active_port = port
|
self._active_port = port
|
||||||
self._manager.connect(port)
|
self._manager.connect(port)
|
||||||
|
|
||||||
|
def matter_payload(self) -> SetupPayload:
|
||||||
|
"""Pairing payload from this box's log (VID/PID/discriminator), defaults otherwise."""
|
||||||
|
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [])
|
||||||
|
|
||||||
# ---- service system -------------------------------------------------
|
# ---- service system -------------------------------------------------
|
||||||
def _service_base(self) -> str:
|
def _service_base(self) -> str:
|
||||||
return str(self._settings.value("service/base", DEFAULT_BASE))
|
return str(self._settings.value("service/base", DEFAULT_BASE))
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Matter pairing QR code on screen, so the technician can scan it from the laptop."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import segno
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
|
||||||
|
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from ..matter.payload import SetupPayload
|
||||||
|
|
||||||
|
|
||||||
|
def qr_pixmap(text: str, size: int = 260) -> QPixmap:
|
||||||
|
"""Black-on-white QR with the quiet zone phones need (4 modules)."""
|
||||||
|
qr = segno.make(text, error="m")
|
||||||
|
matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)]
|
||||||
|
n = len(matrix)
|
||||||
|
module = max(1, size // n)
|
||||||
|
img = QImage(n * module, n * module, QImage.Format.Format_RGB32)
|
||||||
|
img.fill(QColor("white"))
|
||||||
|
p = QPainter(img)
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(QColor("black"))
|
||||||
|
for y, row in enumerate(matrix):
|
||||||
|
for x, dark in enumerate(row):
|
||||||
|
if dark:
|
||||||
|
p.drawRect(x * module, y * module, module, module)
|
||||||
|
p.end()
|
||||||
|
return QPixmap.fromImage(img)
|
||||||
|
|
||||||
|
|
||||||
|
class MatterQrWidget(QWidget):
|
||||||
|
def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.image = QLabel()
|
||||||
|
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.code = QLabel()
|
||||||
|
self.code.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
f = self.code.font()
|
||||||
|
f.setPointSize(f.pointSize() + 4)
|
||||||
|
f.setBold(True)
|
||||||
|
self.code.setFont(f)
|
||||||
|
self.note = QLabel(wordWrap=True)
|
||||||
|
self.note.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.note.setStyleSheet("color:#555")
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.addWidget(self.image)
|
||||||
|
lay.addWidget(self.code)
|
||||||
|
lay.addWidget(self.note)
|
||||||
|
self._size = size
|
||||||
|
self.set_payload(payload)
|
||||||
|
|
||||||
|
def set_payload(self, payload: SetupPayload) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
|
||||||
|
self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
|
||||||
|
src = ("from this box's log" if payload.from_log else
|
||||||
|
"standard code of the v1.0 firmware - connect the box to confirm")
|
||||||
|
self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. "
|
||||||
|
f"Discriminator {payload.discriminator} ({src}). Same as the sticker on the box.")
|
||||||
|
|
||||||
|
|
||||||
|
class MatterQrDialog(QDialog):
|
||||||
|
def __init__(self, payload: SetupPayload, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Matter pairing code")
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.addWidget(MatterQrWidget(payload, 320))
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
lay.addWidget(buttons)
|
||||||
+24
-7
@@ -7,7 +7,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from PySide6.QtCore import QObject, QSettings, Qt, Signal
|
from PySide6.QtCore import QObject, QSettings, Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
@@ -34,6 +33,7 @@ DEFAULT_TEMPLATE = Path(__file__).resolve().parents[4] / "002-SD_card_format" /
|
|||||||
|
|
||||||
class _Bridge(QObject):
|
class _Bridge(QObject):
|
||||||
done = Signal(object)
|
done = Signal(object)
|
||||||
|
listed = Signal(object)
|
||||||
|
|
||||||
|
|
||||||
def _secret_field() -> tuple[QLineEdit, QCheckBox]:
|
def _secret_field() -> tuple[QLineEdit, QCheckBox]:
|
||||||
@@ -52,6 +52,7 @@ class SdTab(QWidget):
|
|||||||
self._settings = QSettings("KOTA", "TechnicianTool")
|
self._settings = QSettings("KOTA", "TechnicianTool")
|
||||||
self._cards: list[Disk] = []
|
self._cards: list[Disk] = []
|
||||||
self._busy = False
|
self._busy = False
|
||||||
|
self._listing = False
|
||||||
|
|
||||||
# --- 1. settings
|
# --- 1. settings
|
||||||
sbox = QGroupBox("1. Settings written to the card (ENV.TXT)")
|
sbox = QGroupBox("1. Settings written to the card (ENV.TXT)")
|
||||||
@@ -135,6 +136,7 @@ class SdTab(QWidget):
|
|||||||
|
|
||||||
self._bridge = _Bridge()
|
self._bridge = _Bridge()
|
||||||
self._bridge.done.connect(self._on_done)
|
self._bridge.done.connect(self._on_done)
|
||||||
|
self._bridge.listed.connect(self._on_listed)
|
||||||
for w in (self.url, self.token, self.ssid, self.password):
|
for w in (self.url, self.token, self.ssid, self.password):
|
||||||
w.textChanged.connect(self._update)
|
w.textChanged.connect(self._update)
|
||||||
self.wifi_box.toggled.connect(self._update)
|
self.wifi_box.toggled.connect(self._update)
|
||||||
@@ -199,13 +201,28 @@ class SdTab(QWidget):
|
|||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
|
|
||||||
def refresh_cards(self) -> None:
|
def refresh_cards(self) -> None:
|
||||||
if self._busy:
|
"""List cards in the background: the UI never waits for a slow card reader."""
|
||||||
|
if self._busy or self._listing:
|
||||||
|
return
|
||||||
|
self._listing = True
|
||||||
|
self.others.setText("Looking for SD cards...")
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
result = list_cards()
|
||||||
|
except Exception as exc: # show it, never leave the tab stuck
|
||||||
|
result = ([], [], str(exc))
|
||||||
|
self._bridge.listed.emit(result)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True, name="sd-list").start()
|
||||||
|
|
||||||
|
def _on_listed(self, result) -> None:
|
||||||
|
self._listing = False
|
||||||
|
self._cards, others = result[0], result[1]
|
||||||
|
if len(result) > 2:
|
||||||
|
self.others.setText(f"Could not list cards: {result[2]}")
|
||||||
|
self._update()
|
||||||
return
|
return
|
||||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
||||||
try:
|
|
||||||
self._cards, others = list_cards()
|
|
||||||
finally:
|
|
||||||
QApplication.restoreOverrideCursor()
|
|
||||||
self.card_list.clear()
|
self.card_list.clear()
|
||||||
for d in self._cards:
|
for d in self._cards:
|
||||||
self.card_list.addItem(QListWidgetItem(d.label))
|
self.card_list.addItem(QListWidgetItem(d.label))
|
||||||
|
|||||||
+3
-2
@@ -1,16 +1,17 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kota-technician-tool"
|
name = "kota-technician-tool"
|
||||||
version = "0.5.0"
|
version = "0.6.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 = [
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
"PySide6-Essentials>=6.7",
|
"PySide6-Essentials>=6.7",
|
||||||
"esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands
|
"esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands
|
||||||
|
"segno>=1.6", # QR code for Matter pairing (pure Python)
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8"]
|
dev = ["pytest>=8", "pyfatfs>=1.0", "setuptools<81"] # pyfatfs (independent FAT32 check) needs pkg_resources
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = false
|
package = false
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
cd "$(dirname "$0")" || exit 1
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
# 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.5"
|
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.6"
|
||||||
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
@@ -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.5"
|
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.6"
|
||||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||||
|
|
||||||
where uv >nul 2>nul
|
where uv >nul 2>nul
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""FAT32/MBR layout: field-level checks against the FAT spec on an in-memory
|
||||||
|
disk, plus an independent FAT implementation (pyfatfs) mounting the result."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kota_tech.sd import fat32
|
||||||
|
|
||||||
|
MiB, GiB = 1024**2, 1024**3
|
||||||
|
|
||||||
|
|
||||||
|
def make_disk(size: int, label="KOTA") -> tuple[bytearray, fat32.Fat32Layout]:
|
||||||
|
lay = fat32.plan(size)
|
||||||
|
disk = bytearray(b"\xAA" * size) # old garbage everywhere
|
||||||
|
zeros, writes = fat32.build(lay, label, 0x12345678, 0xCAFEBABE, size)
|
||||||
|
for off, n in zeros:
|
||||||
|
disk[off:off + n] = bytes(n)
|
||||||
|
for off, data in writes:
|
||||||
|
disk[off:off + len(data)] = data
|
||||||
|
return disk, lay
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def small():
|
||||||
|
return make_disk(96 * MiB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mbr_has_one_fat32_lba_partition(small):
|
||||||
|
disk, lay = small
|
||||||
|
assert disk[510:512] == b"\x55\xAA"
|
||||||
|
status, ptype, lba, count = struct.unpack_from("<B3xB3xII", disk, 0x1BE)
|
||||||
|
assert (status, ptype, lba) == (0, 0x0C, fat32.PART_START // 512)
|
||||||
|
assert count == lay.part_sectors
|
||||||
|
assert disk[0x1CE:0x1FE] == bytes(48) # other entries empty
|
||||||
|
assert struct.unpack_from("<I", disk, 0x1B8)[0] == 0xCAFEBABE
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_sector_fields(small):
|
||||||
|
disk, lay = small
|
||||||
|
b = disk[fat32.PART_START:fat32.PART_START + 512]
|
||||||
|
bps, spc, rsvd, nfats, root_ent, tot16, media, fat16 = struct.unpack_from("<HBHBHHBH", b, 11)
|
||||||
|
assert (bps, spc, rsvd, nfats, root_ent, tot16, media, fat16) == (512, lay.sectors_per_cluster, 32, 2, 0, 0, 0xF8, 0)
|
||||||
|
hidden, tot32, fatsz, _, _, root_clus, fsinfo, bkboot = struct.unpack_from("<IIIHHIHH", b, 28)
|
||||||
|
assert hidden == fat32.PART_START // 512 and tot32 == lay.part_sectors and fatsz == lay.fat_sectors
|
||||||
|
assert (root_clus, fsinfo, bkboot) == (2, 1, 6)
|
||||||
|
assert b[66] == 0x29 and b[71:82] == b"KOTA " and b[82:90] == b"FAT32 " and b[510:] == b"\x55\xAA"
|
||||||
|
assert disk[fat32.PART_START + 6 * 512:fat32.PART_START + 7 * 512] == b # backup boot sector
|
||||||
|
|
||||||
|
|
||||||
|
def test_fat_is_large_enough_and_clusters_valid(small):
|
||||||
|
_, lay = small
|
||||||
|
assert fat32.MIN_CLUSTERS <= lay.clusters
|
||||||
|
assert lay.fat_sectors * 512 // 4 >= lay.clusters + 2
|
||||||
|
assert lay.data_start + lay.clusters * lay.sectors_per_cluster <= lay.part_sectors
|
||||||
|
|
||||||
|
|
||||||
|
def test_fsinfo_and_fat_entries(small):
|
||||||
|
disk, lay = small
|
||||||
|
info = disk[fat32.PART_START + 512:fat32.PART_START + 1024]
|
||||||
|
assert struct.unpack_from("<I", info, 0)[0] == 0x41615252
|
||||||
|
assert struct.unpack_from("<III", info, 484) == (0x61417272, lay.clusters - 1, 3)
|
||||||
|
assert struct.unpack_from("<I", info, 508)[0] == 0xAA550000
|
||||||
|
for fat in range(2):
|
||||||
|
off = fat32.PART_START + (32 + fat * lay.fat_sectors) * 512
|
||||||
|
assert struct.unpack_from("<III", disk, off) == (0x0FFFFFF8, 0x0FFFFFFF, 0x0FFFFFFF)
|
||||||
|
assert disk[off + 12:off + lay.fat_sectors * 512] == bytes(lay.fat_sectors * 512 - 12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_holds_only_the_label(small):
|
||||||
|
disk, lay = small
|
||||||
|
root = fat32.PART_START + lay.data_start * 512
|
||||||
|
assert disk[root:root + 11] == b"KOTA " and disk[root + 11] == 0x08
|
||||||
|
assert disk[root + 32:root + lay.sectors_per_cluster * 512] == bytes(lay.sectors_per_cluster * 512 - 32)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("size,spc", [(4 * GiB, 8), (15 * GiB, 16), (31_457_280_000, 32), (64 * GiB, 32)])
|
||||||
|
def test_real_card_sizes(size, spc):
|
||||||
|
lay = fat32.plan(size)
|
||||||
|
assert lay.sectors_per_cluster == spc
|
||||||
|
assert lay.part_sectors * 512 <= min(size - fat32.PART_START, 32 * GiB)
|
||||||
|
assert fat32.MIN_CLUSTERS <= lay.clusters < fat32.MAX_CLUSTERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_too_small_card_refused():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
fat32.plan(20 * MiB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_independent_fat_implementation_mounts_it(small, tmp_path):
|
||||||
|
pyfat = pytest.importorskip("pyfatfs.PyFat")
|
||||||
|
disk, lay = small
|
||||||
|
img = tmp_path / "part.img"
|
||||||
|
img.write_bytes(bytes(disk[fat32.PART_START:fat32.PART_START + lay.part_sectors * 512]))
|
||||||
|
fs = pyfat.PyFat()
|
||||||
|
fs.open(str(img))
|
||||||
|
assert fs.fat_type == pyfat.PyFat.FAT_TYPE_FAT32
|
||||||
|
assert fs.bpb_header["BPB_TotSec32"] == lay.part_sectors
|
||||||
|
fs.close()
|
||||||
|
from pyfatfs.PyFatFS import PyFatFS
|
||||||
|
with PyFatFS(str(img)) as ffs: # write + read back a file like ENV.TXT
|
||||||
|
ffs.writebytes("/ENV.TXT", b"INFLUX_URL=x\n")
|
||||||
|
assert ffs.readbytes("/ENV.TXT") == b"INFLUX_URL=x\n"
|
||||||
@@ -244,3 +244,31 @@ def test_non_firmware_file_refused(tmp_path, image):
|
|||||||
p.write_bytes(b"hello firmware team")
|
p.write_bytes(b"hello firmware team")
|
||||||
with pytest.raises(ImageError, match="not an ESP32 firmware"):
|
with pytest.raises(ImageError, match="not an ESP32 firmware"):
|
||||||
FirmwareLibrary(tmp_path / "lib").import_file(p, reference=image)
|
FirmwareLibrary(tmp_path / "lib").import_file(p, reference=image)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- bundled developer build (v0.6)
|
||||||
|
|
||||||
|
from kota_tech.flash.image import bundled_images # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_developer_build_is_labelled_and_not_default():
|
||||||
|
imgs = bundled_images()
|
||||||
|
assert [i.origin for i in imgs] == ["factory", "developer"] # factory first
|
||||||
|
dev = imgs[1]
|
||||||
|
assert dev.built_by == "Krzysztof Kallas" and "built by Krzysztof Kallas" in dev.label()
|
||||||
|
assert dev.kind == "merged" and dev.app.elf_sha.startswith("c94ba0e9b")
|
||||||
|
assert dev.partition_table == imgs[0].partition_table # App-only safe on v1.0 boxes
|
||||||
|
assert [i.origin for i in factory_images_only()] == ["factory"]
|
||||||
|
|
||||||
|
|
||||||
|
def factory_images_only():
|
||||||
|
from kota_tech.flash.image import factory_images
|
||||||
|
return factory_images()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_records_who_built_it(image, tmp_path):
|
||||||
|
lib = FirmwareLibrary(tmp_path / "lib")
|
||||||
|
img = lib.import_file(_app_only_file(image, tmp_path), reference=image, built_by=" Anna Nowak ")
|
||||||
|
assert img.built_by == "Anna Nowak" and "built by Anna Nowak" in img.label()
|
||||||
|
again, _ = lib.images(image)
|
||||||
|
assert again[0].built_by == "Anna Nowak"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Matter onboarding payload, checked against the real sticker of the bench box
|
||||||
|
(QR decoded from matter_commissioning_qr.png: MT:Y.K9042C00KA0648G00)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import segno
|
||||||
|
|
||||||
|
from kota_tech.core.logfile import read_log_file
|
||||||
|
from kota_tech.matter.payload import SetupPayload, payload_from_log, verhoeff_check
|
||||||
|
|
||||||
|
STICKER_QR = "MT:Y.K9042C00KA0648G00"
|
||||||
|
STICKER_MANUAL = "34970112332" # printed as 3497-011-2332
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_payload_matches_sticker():
|
||||||
|
p = SetupPayload()
|
||||||
|
assert p.qr_text() == STICKER_QR
|
||||||
|
assert p.manual_code() == STICKER_MANUAL
|
||||||
|
assert p.manual_code_pretty() == "3497-011-2332"
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_from_real_boot_log():
|
||||||
|
lines = [ln.text for ln in read_log_file(Path(__file__).parent / "fixtures" / "serial_log_20260917_162548.txt")]
|
||||||
|
p = payload_from_log(lines)
|
||||||
|
assert p.from_log and (p.vendor_id, p.product_id, p.discriminator) == (0xFFF1, 0x8000, 3840)
|
||||||
|
assert p.qr_text() == STICKER_QR
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_discriminator_changes_both_codes():
|
||||||
|
p = SetupPayload(discriminator=1234)
|
||||||
|
assert p.qr_text() != STICKER_QR and p.manual_code() != STICKER_MANUAL
|
||||||
|
assert p.qr_text().startswith("MT:") and len(p.qr_text()) == 3 + 19
|
||||||
|
|
||||||
|
|
||||||
|
def test_verhoeff():
|
||||||
|
assert verhoeff_check("3497011233") == 2
|
||||||
|
assert verhoeff_check("236") == 3 # textbook example
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_renders():
|
||||||
|
qr = segno.make(STICKER_QR, error="m")
|
||||||
|
assert qr.symbol_size(border=0)[0] >= 21
|
||||||
+23
-10
@@ -5,9 +5,12 @@ import plistlib
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from kota_tech.sd.disks import MAX_CARD_BYTES, Disk, parse_macos, parse_windows
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kota_tech.sd.disks import MAX_CARD_BYTES, Disk, Volume, parse_macos, parse_windows
|
||||||
from kota_tech.sd.envfile import EnvConfig, parse_env
|
from kota_tech.sd.envfile import EnvConfig, parse_env
|
||||||
from kota_tech.sd.provision import check_card, windows_format_script, write_env
|
from kota_tech.sd.provision import check_card, win_format_command, write_env
|
||||||
|
|
||||||
URL = "https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota"
|
URL = "https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota"
|
||||||
TOKEN = "T" * 86 + "==" # shape of an InfluxDB token, not a real one
|
TOKEN = "T" * 86 + "==" # shape of an InfluxDB token, not a real one
|
||||||
@@ -108,15 +111,25 @@ def test_parse_macos_excludes_system_disk():
|
|||||||
|
|
||||||
# -------------------------------------------------------- format script
|
# -------------------------------------------------------- format script
|
||||||
|
|
||||||
def test_windows_script_rechecks_disk_before_erasing(tmp_path):
|
def test_elevated_helper_command(tmp_path):
|
||||||
disk = Disk("5", "SDXC Card", 63_864_569_856, "SD")
|
disk = Disk("5", "SDXC Card", 63_864_569_856, "SD")
|
||||||
ps = windows_format_script(disk, tmp_path / "r.json")
|
prog, params, cwd = win_format_command(disk, tmp_path / "r.json")
|
||||||
erase = ps.index("Clear-Disk")
|
assert params.startswith("-m kota_tech.sd.win_format --disk 5 --size 63864569856 --label KOTA")
|
||||||
for guard in ("IsSystem -or $d.IsBoot", "'MMC','SD','USB'", "-ne 63864569856"):
|
assert (Path(cwd) / "kota_tech").is_dir() # helper is importable from there
|
||||||
assert guard in ps and ps.index(guard) < erase
|
|
||||||
assert "-PartitionStyle MBR" in ps and "-FileSystem FAT32" in ps
|
|
||||||
assert str(32 * 1024**3) in ps # FAT32 partition capped at 32 GB
|
@pytest.mark.skipif(sys.platform != "win32", reason="Windows formatter")
|
||||||
assert "NewFileSystemLabel 'KOTA'" in ps
|
@pytest.mark.parametrize("disks,msg", [
|
||||||
|
([], "not a USB/SD/MMC"), # disk vanished / internal
|
||||||
|
([Disk("5", "SD", 1_000, "SD")], "changed since it was selected"), # swapped card
|
||||||
|
([Disk("5", "SD", 31_457_280_000, "SD", [Volume("C:\\", "NTFS", "")])], "Windows drive"),
|
||||||
|
])
|
||||||
|
def test_formatter_guard_refuses_before_writing(monkeypatch, disks, msg):
|
||||||
|
from kota_tech.sd import win_format
|
||||||
|
monkeypatch.setattr(win_format.win_disks, "list_disks", lambda buses: disks)
|
||||||
|
monkeypatch.setattr(win_format, "RawDisk", lambda *a: pytest.fail("must not open the disk for writing"))
|
||||||
|
with pytest.raises(win_format.FormatError, match=msg):
|
||||||
|
win_format.format_card(5, 31_457_280_000, "KOTA", {})
|
||||||
|
|
||||||
|
|
||||||
def test_large_disks_are_not_cards():
|
def test_large_disks_are_not_cards():
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ resolution-markers = [
|
|||||||
"platform_machine != 'x86_64' or sys_platform != 'darwin'",
|
"platform_machine != 'x86_64' or sys_platform != 'darwin'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "appdirs"
|
||||||
|
version = "1.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argcomplete"
|
name = "argcomplete"
|
||||||
version = "3.7.2"
|
version = "3.7.2"
|
||||||
@@ -207,6 +216,20 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/84/19/f68b27fd43564d69277e956d13b8c2b352e7f1af4fb5d6e5c43e5419e61c/esptool-4.12.0.tar.gz", hash = "sha256:d797f6529d582173508baf0572542ddadf53aae8c92c84ea9f420f242464103c", size = 463840, upload-time = "2026-07-14T08:36:51.891Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/84/19/f68b27fd43564d69277e956d13b8c2b352e7f1af4fb5d6e5c43e5419e61c/esptool-4.12.0.tar.gz", hash = "sha256:d797f6529d582173508baf0572542ddadf53aae8c92c84ea9f420f242464103c", size = 463840, upload-time = "2026-07-14T08:36:51.891Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs"
|
||||||
|
version = "2.4.16"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "appdirs" },
|
||||||
|
{ name = "setuptools" },
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261, upload-time = "2022-05-02T09:25:52.363Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -227,17 +250,20 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kota-technician-tool"
|
name = "kota-technician-tool"
|
||||||
version = "0.4.0"
|
version = "0.6.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "esptool" },
|
{ name = "esptool" },
|
||||||
{ name = "pyserial" },
|
{ name = "pyserial" },
|
||||||
{ name = "pyside6-essentials" },
|
{ name = "pyside6-essentials" },
|
||||||
|
{ name = "segno" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pyfatfs" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "setuptools" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -245,10 +271,15 @@ requires-dist = [
|
|||||||
{ name = "esptool", specifier = "~=4.8" },
|
{ name = "esptool", specifier = "~=4.8" },
|
||||||
{ name = "pyserial", specifier = ">=3.5" },
|
{ name = "pyserial", specifier = ">=3.5" },
|
||||||
{ name = "pyside6-essentials", specifier = ">=6.7" },
|
{ name = "pyside6-essentials", specifier = ">=6.7" },
|
||||||
|
{ name = "segno", specifier = ">=1.6" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
dev = [
|
||||||
|
{ name = "pyfatfs", specifier = ">=1.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8" },
|
||||||
|
{ name = "setuptools", specifier = "<81" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
@@ -277,6 +308,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyfatfs"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "fs" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/44/3f/d08f1dbc44a7eef9c7fb355b83423fbd15bb3e487c250479a2c179cb39bf/pyfatfs-1.1.0.tar.gz", hash = "sha256:9725ccd0a4da1c09c27358abbf10f08c043ac84210af576803e087f51a2b30e0", size = 55459, upload-time = "2024-03-03T20:50:43.227Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/de/feb531b25eb2315aa73a7dba335c253ee0075ff7f55b6fe742fb6d492ade/pyfatfs-1.1.0-py3-none-any.whl", hash = "sha256:7401fd39d9e92f531b2abea5b5d1d2c19d5461f4ba9ce8819b1ba35aea73c65d", size = 37932, upload-time = "2024-03-03T20:50:41.426Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
@@ -372,6 +415,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" },
|
{ url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "segno"
|
||||||
|
version = "1.6.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/2e/b396f750c53f570055bf5a9fc1ace09bed2dff013c73b7afec5702a581ba/segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3", size = 1628586, upload-time = "2025-03-12T22:12:53.324Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "setuptools"
|
||||||
|
version = "80.10.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shiboken6"
|
name = "shiboken6"
|
||||||
version = "6.11.2"
|
version = "6.11.2"
|
||||||
|
|||||||
Reference in new issue
Block a user