Technician tool v0.6

Imported from the Google Drive folder 004-technician_tool/v0.6.
This commit is contained in:
kkallas committed 2026-09-28 17:31:03 +02:00
1 parent f5b32e1554
commit 22bd853ecf
30 files changed
+1076 -123

No files matched your search

+1
View File
@@ -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
-11
View File
@@ -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."
+1 -1
View File
@@ -1,3 +1,3 @@
"""KOTA sensorbox technician tool.""" """KOTA sensorbox technician tool."""
__version__ = "0.5.0" __version__ = "0.6.0"
+3
View File
@@ -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)
+6
View File
@@ -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,})")
+2 -1
View File
@@ -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", [])}
+2 -1
View File
@@ -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
View File
@@ -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()
View File
Whitespace-only changes.
+96
View File
@@ -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()
+4 -4
View File
@@ -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 = {}
+135
View File
@@ -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
View File
@@ -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)
+161
View File
@@ -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
+192
View File
@@ -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())
+4
View File
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QPushButton,
QSizePolicy, QSizePolicy,
QSplitter, QSplitter,
QTextBrowser, QTextBrowser,
@@ -99,6 +100,9 @@ class DiagnosticsPanel(QWidget):
lay.addLayout(led_row) lay.addLayout(led_row)
lay.addWidget(_heading("Firmware")) lay.addWidget(_heading("Firmware"))
lay.addWidget(self.firmware) lay.addWidget(self.firmware)
self.qr_btn = QPushButton("Matter pairing QR code...")
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
lay.addWidget(_heading("Start-up progress (current boot)")) lay.addWidget(_heading("Start-up progress (current boot)"))
lay.addWidget(self.milestones) lay.addWidget(self.milestones)
lay.addWidget(_heading("Findings - root causes first; click one for details")) lay.addWidget(_heading("Findings - root causes first; click one for details"))
+12 -5
View File
@@ -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
+11 -2
View File
@@ -18,6 +18,8 @@ from PySide6.QtWidgets import (
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
from .matter_qr import MatterQrWidget
END_STYLE = { # end severity -> (border, background) END_STYLE = { # end severity -> (border, background)
"ok": ("#2e9e44", "#e3f4e7"), "ok": ("#2e9e44", "#e3f4e7"),
@@ -52,9 +54,11 @@ def _esc(s: str) -> str:
class GuidePanel(QWidget): class GuidePanel(QWidget):
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None): def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None,
matter_payload=None):
super().__init__(parent) super().__init__(parent)
self.runner, self.engine = runner, engine self.runner, self.engine = runner, engine
self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
self._shown_key: tuple | None = None self._shown_key: tuple | None = None
# --- picker (no guide running) # --- picker (no guide running)
@@ -93,7 +97,8 @@ class GuidePanel(QWidget):
self.end_box = QLabel(wordWrap=True) self.end_box = QLabel(wordWrap=True)
self.end_box.setTextFormat(Qt.TextFormat.RichText) self.end_box.setTextFormat(Qt.TextFormat.RichText)
self.end_box.setMargin(10) self.end_box.setMargin(10)
for w in (self.guide_title, self.step_text): self.qr = MatterQrWidget(SetupPayload(), 220)
for w in (self.guide_title, self.step_text, self.qr):
cl.addWidget(w) cl.addWidget(w)
cl.addLayout(self.options) cl.addLayout(self.options)
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box): for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
@@ -192,6 +197,10 @@ class GuidePanel(QWidget):
is_end = v.end is not None is_end = v.end is not None
self.step_text.setVisible(not is_end) self.step_text.setVisible(not is_end)
self.step_text.setText(_esc(v.text)) self.step_text.setText(_esc(v.text))
show_qr = bool(v.step and v.step.show_qr)
if show_qr:
self.qr.set_payload(self._matter_payload())
self.qr.setVisible(show_qr)
for opt_key, label in v.options: for opt_key, label in v.options:
b = QPushButton(label) b = QPushButton(label)
b.setMinimumHeight(34) b.setMinimumHeight(34)
+8 -1
View File
@@ -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))
+72
View File
@@ -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)
+23 -6
View File
@@ -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 return
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) self._listing = True
self.others.setText("Looking for SD cards...")
def worker():
try: try:
self._cards, others = list_cards() result = list_cards()
finally: except Exception as exc: # show it, never leave the tab stuck
QApplication.restoreOverrideCursor() 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
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
View File
@@ -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
View File
@@ -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
View File
@@ -3,7 +3,7 @@ setlocal
cd /d "%~dp0" cd /d "%~dp0"
rem Keep the virtual environment out of the (Google Drive) tool folder. rem Keep the virtual environment out of the (Google Drive) tool folder.
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.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
+103
View File
@@ -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"
+28
View File
@@ -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"
+42
View File
@@ -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
View File
@@ -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():
Generated
+63 -2
View File
@@ -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"