Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22bd853ecf | ||
|
|
f5b32e1554 |
No files matched your search
@@ -0,0 +1,30 @@
|
||||
You are testing the KOTA sensorbox technician tool v0.5 on macOS. The current folder is
|
||||
004-technician_tool/v0.5 (a Python/PySide6 desktop app; it has only been tested on Windows so far).
|
||||
|
||||
Rules:
|
||||
- Python only via uv or conda/mamba. Never use bare/system python or pip.
|
||||
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin.
|
||||
- Do not push anything to Git or GitLab. This is a local demo.
|
||||
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
|
||||
Before formatting, show me the exact disk (identifier, name, size, current files).
|
||||
- Do not change code without telling me. Report bugs with evidence (commands and output).
|
||||
|
||||
Tests, in order. Report pass/fail with evidence for each:
|
||||
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
|
||||
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q`
|
||||
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
|
||||
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the
|
||||
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the
|
||||
reset and whether the tool reconnects.
|
||||
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
|
||||
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself,
|
||||
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output.
|
||||
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed
|
||||
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT".
|
||||
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical
|
||||
to the template, and eject the card.
|
||||
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine
|
||||
if the page itself cannot prefill yet).
|
||||
|
||||
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
|
||||
suggested fixes (file:line).
|
||||
@@ -8,4 +8,5 @@ dependencies:
|
||||
- pyserial>=3.5
|
||||
- pyside6>=6.7
|
||||
- esptool>=4.8,<5
|
||||
- segno>=1.6
|
||||
- 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."""
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.6.0"
|
||||
@@ -25,6 +25,7 @@ from ..core.logline import LineKind, LogLine
|
||||
from .firmware import FirmwareInfo
|
||||
|
||||
RULES_PATH = Path(__file__).with_name("rules.toml")
|
||||
_MAC_LINE = re.compile(r"Device MAC address: ([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})")
|
||||
|
||||
|
||||
class Severity(IntEnum):
|
||||
@@ -278,6 +279,8 @@ class DiagnosticsEngine:
|
||||
self._tool_reset_at: float | None = None
|
||||
self._panic_tail = 0 # RAW lines after a panic line to keep
|
||||
self.technician: list[str] = []
|
||||
self.mac: str | None = None # from "ID: Device MAC address: ..." at boot
|
||||
self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line
|
||||
|
||||
@property
|
||||
def boot(self) -> BootRecord:
|
||||
@@ -317,6 +320,10 @@ class DiagnosticsEngine:
|
||||
self._crash_finding().evidence.append(text)
|
||||
|
||||
self.firmware.observe(text)
|
||||
if m := _MAC_LINE.search(text):
|
||||
self.mac = m.group(1).upper()
|
||||
if "Advertise commission parameter" in text:
|
||||
self.matter_advert = text
|
||||
matched = False
|
||||
for rule in self.rules:
|
||||
m = rule.pattern.search(text)
|
||||
|
||||
@@ -26,6 +26,12 @@ KNOWN: dict[str, KnownFirmware] = {
|
||||
sd_wifi=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,})")
|
||||
|
||||
@@ -57,6 +57,7 @@ class Step:
|
||||
skip_if_finding: str | None = None
|
||||
skip_unless_feature: 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]:
|
||||
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)),
|
||||
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_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", ())))
|
||||
for e in g.get("end", [])}
|
||||
|
||||
@@ -113,7 +113,8 @@ next = "pair_start"
|
||||
[[guide.step]]
|
||||
id = "pair_start"
|
||||
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'
|
||||
timeout_s = 300
|
||||
on_ok = "pair_wifi"
|
||||
@@ -379,7 +380,7 @@ severity = "critical"
|
||||
title = "SD card is unreadable or has wrong settings"
|
||||
text = "Even after re-seating, the card or its ENV.TXT cannot be used."
|
||||
actions = [
|
||||
"Re-provision the card with the tool (SD tab, coming in v0.5) or use a new card.",
|
||||
"Re-provision the card with the tool (SD card tab) or use a new card.",
|
||||
"The Diagnosis tab shows which step failed (card, file system or ENV.TXT keys).",
|
||||
]
|
||||
|
||||
|
||||
+203
-52
@@ -1,18 +1,26 @@
|
||||
"""Official firmware images: validation and layout.
|
||||
"""Firmware images: validation, integrity and layout.
|
||||
|
||||
Only images listed in firmware/firmware.toml with a matching SHA-256 are
|
||||
offered for flashing: boxes normally get new firmware over OTA, so the tool
|
||||
only ever writes the official factory image.
|
||||
Two origins:
|
||||
* factory - bundled with the tool (firmware/firmware.toml), SHA-256 locked;
|
||||
* developer - a newer build the firmware team sends to technicians,
|
||||
imported into a per-user library (~/KotaTechnicianTool/firmware).
|
||||
Two kinds:
|
||||
* merged - bootloader at 0x0 + partition table + app (like new_system.bin);
|
||||
App-only or Full flashing;
|
||||
* app - the application binary only (build/kota-sensorboard.bin);
|
||||
App-only flashing, laid out like the factory image.
|
||||
|
||||
The factory image is a merged ESP32-S3 image (bootloader at 0x0, partition
|
||||
table at 0x8000, app at ota_0). App-only flashing takes the app out of it
|
||||
and writes it to ota_0; the layout comes from the image's own partition
|
||||
table, not from constants.
|
||||
Every image is checked structurally (ESP32-S3, kota-sensorboard app
|
||||
descriptor, segment checksum and the appended SHA-256) so a truncated or
|
||||
corrupted file is refused before anything is written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import struct
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
@@ -20,6 +28,8 @@ from pathlib import Path
|
||||
|
||||
FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware"
|
||||
MANIFEST = FIRMWARE_DIR / "firmware.toml"
|
||||
LIBRARY_DIR = Path.home() / "KotaTechnicianTool" / "firmware"
|
||||
LIBRARY_INDEX = "library.json"
|
||||
|
||||
PT_OFFSET = 0x8000
|
||||
PT_SIZE = 0xC00
|
||||
@@ -27,6 +37,8 @@ ESP32S3_CHIP_ID = 9
|
||||
IMAGE_MAGIC = 0xE9
|
||||
APP_DESC_MAGIC = 0xABCD5432
|
||||
FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"}
|
||||
_MONTHS = {m: i for i, m in enumerate(
|
||||
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 1)}
|
||||
|
||||
|
||||
class ImageError(ValueError):
|
||||
@@ -46,9 +58,18 @@ class Partition:
|
||||
class AppInfo:
|
||||
project: str
|
||||
version: str
|
||||
built: str
|
||||
built: str # "Jun 29 2026 11:03:32" as in the app descriptor
|
||||
idf: str
|
||||
elf_sha: str # hex
|
||||
elf_sha: str # hex
|
||||
|
||||
@property
|
||||
def built_at(self) -> dt.datetime | None:
|
||||
try:
|
||||
mon, day, year, hms = self.built.split()
|
||||
h, m, s = (int(x) for x in hms.split(":"))
|
||||
return dt.datetime(int(year), _MONTHS[mon], int(day), h, m, s)
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -56,36 +77,47 @@ class FirmwareImage:
|
||||
path: Path
|
||||
name: str
|
||||
notes: str
|
||||
origin: str # "factory" | "developer"
|
||||
kind: str # "merged" | "app"
|
||||
sha256: str
|
||||
flash_size: str
|
||||
partitions: tuple[Partition, ...]
|
||||
partition_table: bytes # expected table on the box
|
||||
app_offset: int # where the app starts inside the file
|
||||
app_length: int # exact app image length (from its structure)
|
||||
app: AppInfo
|
||||
built_by: str = "" # who compiled a developer build (shown in the UI)
|
||||
|
||||
@property
|
||||
def data(self) -> bytes:
|
||||
return self.path.read_bytes()
|
||||
|
||||
@property
|
||||
def is_developer(self) -> bool:
|
||||
return self.origin == "developer"
|
||||
|
||||
def partition(self, label: str) -> Partition:
|
||||
for p in self.partitions:
|
||||
if p.label == label:
|
||||
return p
|
||||
raise ImageError(f"partition {label!r} not in image")
|
||||
|
||||
@property
|
||||
def partition_table(self) -> bytes:
|
||||
return self.data[PT_OFFSET:PT_OFFSET + PT_SIZE]
|
||||
|
||||
def app_bytes(self) -> bytes:
|
||||
"""The application image stored in the ota_0 region of the merged image."""
|
||||
# No stripping of trailing 0xFF: the image ends in a SHA-256 digest
|
||||
# that may legitimately end in 0xFF. The merged file ends with the app.
|
||||
ota0 = self.partition("ota_0")
|
||||
return self.data[ota0.offset:ota0.offset + ota0.size]
|
||||
return self.data[self.app_offset:self.app_offset + self.app_length]
|
||||
|
||||
def blank_otadata(self) -> bytes:
|
||||
"""Erased otadata: the bootloader then boots ota_0."""
|
||||
return b"\xff" * self.partition("otadata").size
|
||||
|
||||
def label(self) -> str:
|
||||
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}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsing
|
||||
|
||||
def parse_partition_table(raw: bytes) -> tuple[Partition, ...]:
|
||||
parts = []
|
||||
@@ -103,50 +135,169 @@ def parse_partition_table(raw: bytes) -> tuple[Partition, ...]:
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def _app_info(data: bytes, offset: int) -> AppInfo:
|
||||
if len(data) < offset + 0x20 + 256 or data[offset] != IMAGE_MAGIC:
|
||||
raise ImageError(f"no application image at 0x{offset:x}")
|
||||
d = data[offset + 0x20:offset + 0x20 + 256]
|
||||
if struct.unpack_from("<I", d)[0] != APP_DESC_MAGIC:
|
||||
raise ImageError("application has no ESP-IDF app descriptor")
|
||||
s = lambda o, n: d[o:o + n].split(b"\0")[0].decode("ascii", "replace")
|
||||
return AppInfo(project=s(48, 32), version=s(16, 32), built=f"{s(96, 16)} {s(80, 16)}",
|
||||
idf=s(112, 32), elf_sha=d[144:176].hex())
|
||||
def app_image_length(data: bytes, offset: int = 0) -> int:
|
||||
"""Validate an ESP app image at `offset`; return its exact length.
|
||||
|
||||
|
||||
def inspect_image(path: Path, name: str = "", notes: str = "") -> FirmwareImage:
|
||||
data = path.read_bytes()
|
||||
if len(data) < PT_OFFSET + PT_SIZE or data[0] != IMAGE_MAGIC:
|
||||
raise ImageError("not a merged ESP32 image (no bootloader at offset 0)")
|
||||
chip_id = struct.unpack_from("<H", data, 12)[0]
|
||||
Checks the segment checksum and, when the header says so, the appended
|
||||
SHA-256 - the same checks the bootloader performs before booting it.
|
||||
"""
|
||||
if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC:
|
||||
raise ImageError(f"no firmware image at 0x{offset:x}")
|
||||
segments = data[offset + 1]
|
||||
chip_id = struct.unpack_from("<H", data, offset + 12)[0]
|
||||
if chip_id != ESP32S3_CHIP_ID:
|
||||
raise ImageError(f"image is for chip id {chip_id}, not ESP32-S3")
|
||||
flash_size = FLASH_SIZES.get(data[3] >> 4, "?")
|
||||
parts = parse_partition_table(data[PT_OFFSET:PT_OFFSET + PT_SIZE])
|
||||
labels = {p.label for p in parts}
|
||||
for need in ("nvs", "otadata", "ota_0"):
|
||||
if need not in labels:
|
||||
raise ImageError(f"partition table has no {need!r} partition")
|
||||
ota0 = next(p for p in parts if p.label == "ota_0")
|
||||
app = _app_info(data, ota0.offset)
|
||||
if app.project != "kota-sensorboard":
|
||||
raise ImageError(f"image is {app.project!r}, not kota-sensorboard")
|
||||
return FirmwareImage(path, name or path.name, notes, hashlib.sha256(data).hexdigest(),
|
||||
flash_size, parts, app)
|
||||
hash_appended = data[offset + 23] == 1
|
||||
pos, checksum = offset + 24, 0xEF
|
||||
for _ in range(segments):
|
||||
if pos + 8 > len(data):
|
||||
raise ImageError("image is truncated (segment header missing)")
|
||||
_, seg_len = struct.unpack_from("<II", data, pos)
|
||||
pos += 8
|
||||
if pos + seg_len > len(data):
|
||||
raise ImageError("image is truncated (segment data missing)")
|
||||
for b in data[pos:pos + seg_len]:
|
||||
checksum ^= b
|
||||
pos += seg_len
|
||||
pos += 15 - ((pos - offset) % 16) # pad so the checksum ends a 16-byte block
|
||||
if pos >= len(data) or data[pos] != checksum:
|
||||
raise ImageError("image checksum is wrong - the file is corrupted")
|
||||
pos += 1
|
||||
if hash_appended:
|
||||
if pos + 32 > len(data):
|
||||
raise ImageError("image is truncated (SHA-256 missing)")
|
||||
if hashlib.sha256(data[offset:pos]).digest() != data[pos:pos + 32]:
|
||||
raise ImageError("image SHA-256 does not match its content - the file is corrupted")
|
||||
pos += 32
|
||||
return pos - offset
|
||||
|
||||
|
||||
def official_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
||||
"""Images from the manifest whose file exists and matches its SHA-256."""
|
||||
def _app_info(data: bytes, offset: int) -> AppInfo:
|
||||
d = data[offset + 0x20:offset + 0x20 + 256]
|
||||
if len(d) < 256 or struct.unpack_from("<I", d)[0] != APP_DESC_MAGIC:
|
||||
raise ImageError("firmware has no ESP-IDF app descriptor")
|
||||
s = lambda o, n: d[o:o + n].split(b"\0")[0].decode("ascii", "replace")
|
||||
info = AppInfo(project=s(48, 32), version=s(16, 32), built=f"{s(96, 16)} {s(80, 16)}",
|
||||
idf=s(112, 32), elf_sha=d[144:176].hex())
|
||||
if info.project != "kota-sensorboard":
|
||||
raise ImageError(f"firmware is {info.project!r}, not kota-sensorboard")
|
||||
return info
|
||||
|
||||
|
||||
def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "factory",
|
||||
reference: FirmwareImage | None = None, built_by: str = "") -> FirmwareImage:
|
||||
"""Validate a merged or app-only image. App-only images need a `reference`
|
||||
(the factory image) that provides the flash layout."""
|
||||
data = path.read_bytes()
|
||||
if len(data) >= PT_OFFSET + PT_SIZE and data[0] == IMAGE_MAGIC and data[PT_OFFSET:PT_OFFSET + 2] == b"\xaa\x50":
|
||||
app_image_length(data, 0) # bootloader must be intact
|
||||
table = data[PT_OFFSET:PT_OFFSET + PT_SIZE]
|
||||
parts = parse_partition_table(table)
|
||||
labels = {p.label for p in parts}
|
||||
for need in ("nvs", "otadata", "ota_0"):
|
||||
if need not in labels:
|
||||
raise ImageError(f"partition table has no {need!r} partition")
|
||||
ota0 = next(p for p in parts if p.label == "ota_0")
|
||||
kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?")
|
||||
elif data[:1] == bytes([IMAGE_MAGIC]):
|
||||
if reference is None:
|
||||
raise ImageError("app-only image needs the factory image as layout reference")
|
||||
kind, app_off = "app", 0
|
||||
parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size
|
||||
ota0 = next(p for p in parts if p.label == "ota_0")
|
||||
else:
|
||||
raise ImageError("not an ESP32 firmware file")
|
||||
length = app_image_length(data, app_off)
|
||||
if length > ota0.size:
|
||||
raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)")
|
||||
app = _app_info(data, app_off)
|
||||
return FirmwareImage(path, name or path.name, notes, origin, kind, hashlib.sha256(data).hexdigest(),
|
||||
flash_size, parts, table, app_off, length, app, built_by)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- catalogue
|
||||
|
||||
def bundled_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
||||
"""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():
|
||||
return []
|
||||
entries = tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", [])
|
||||
out = []
|
||||
for e in entries:
|
||||
for e in tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", []):
|
||||
path = manifest.parent / e["file"]
|
||||
if not path.exists():
|
||||
continue
|
||||
img = inspect_image(path, e.get("name", ""), e.get("notes", ""))
|
||||
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():
|
||||
raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt")
|
||||
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:
|
||||
"""Developer builds imported by the technician (persist across tool versions)."""
|
||||
|
||||
def __init__(self, root: Path = LIBRARY_DIR):
|
||||
self.root = root
|
||||
|
||||
def _index(self) -> list[dict]:
|
||||
f = self.root / LIBRARY_INDEX
|
||||
return json.loads(f.read_text(encoding="utf-8")) if f.exists() else []
|
||||
|
||||
def _save(self, entries: list[dict]) -> None:
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
(self.root / LIBRARY_INDEX).write_text(json.dumps(entries, indent=2), encoding="utf-8")
|
||||
|
||||
def images(self, reference: FirmwareImage | None) -> tuple[list[FirmwareImage], list[str]]:
|
||||
"""(valid images, problems) - a changed or broken file is reported, not offered."""
|
||||
imgs, problems = [], []
|
||||
for e in self._index():
|
||||
path = self.root / e["file"]
|
||||
try:
|
||||
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "developer", reference,
|
||||
e.get("built_by", ""))
|
||||
if img.sha256 != e["sha256"]:
|
||||
raise ImageError("file changed since it was imported")
|
||||
imgs.append(img)
|
||||
except (OSError, ImageError) as exc:
|
||||
problems.append(f"{e.get('original', e['file'])}: {exc}")
|
||||
return imgs, problems
|
||||
|
||||
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
|
||||
entries = self._index()
|
||||
existing = next((e for e in entries if e["sha256"] == img.sha256), None)
|
||||
if existing:
|
||||
return inspect_image(self.root / existing["file"], existing["name"], existing["notes"],
|
||||
"developer", reference, existing.get("built_by", ""))
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
fname = f"{img.app.elf_sha[:9]}_{img.sha256[:8]}.bin"
|
||||
shutil.copyfile(src, self.root / fname)
|
||||
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,
|
||||
"imported": dt.datetime.now().isoformat(timespec="seconds"), "notes": notes,
|
||||
"built_by": built_by})
|
||||
self._save(entries)
|
||||
return inspect_image(self.root / fname, name, notes, "developer", reference, built_by)
|
||||
|
||||
def remove(self, sha256: str) -> None:
|
||||
entries = self._index()
|
||||
keep = [e for e in entries if e["sha256"] != sha256]
|
||||
for e in entries:
|
||||
if e["sha256"] == sha256:
|
||||
(self.root / e["file"]).unlink(missing_ok=True)
|
||||
self._save(keep)
|
||||
|
||||
|
||||
# Backwards-compatible name used by earlier callers/tests.
|
||||
official_images = factory_images
|
||||
@@ -84,8 +84,11 @@ def build_plan(image: FirmwareImage, mode: Mode, port: str, workdir: Path,
|
||||
hex(otadata.offset), str(ota_file), hex(ota0.offset), str(app_file)],
|
||||
est_s=20))
|
||||
else:
|
||||
if image.kind != "merged":
|
||||
raise ValueError("Full mode needs a complete image (bootloader + partition table + app); "
|
||||
"this file contains the app only - use App-only mode.")
|
||||
plan.steps.append(Step(
|
||||
"write", "Write the complete factory image (erases pairing and OTA registration)",
|
||||
"write", "Write the complete image (erases pairing and OTA registration)",
|
||||
_common(port, "hard_reset") + write_opts + ["0x0", str(image.path)],
|
||||
est_s=25))
|
||||
return plan
|
||||
|
||||
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()
|
||||
Whitespace-only changes.
@@ -0,0 +1,128 @@
|
||||
"""Find SD cards (removable disks) on Windows and macOS.
|
||||
|
||||
Safety first: only USB/SD/MMC-attached disks that are not the system or
|
||||
boot disk and not larger than MAX_CARD_BYTES are ever offered. Parsers are
|
||||
pure functions over the OS tool output so they can be tested without
|
||||
hardware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
MAX_CARD_BYTES = 256 * 1024**3 # larger "removable" disks are probably not SD cards
|
||||
CARD_BUSES = {"USB", "SD", "MMC"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Volume:
|
||||
mount: str # "E:\\" on Windows, "/Volumes/KOTA" on macOS ("" if not mounted)
|
||||
filesystem: str # "FAT32", "exFAT", "MS-DOS FAT32", ...
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Disk:
|
||||
id: str # Windows disk number / macOS "/dev/disk4"
|
||||
name: str
|
||||
size: int
|
||||
bus: str
|
||||
volumes: list[Volume] = field(default_factory=list)
|
||||
has_media: bool = True
|
||||
|
||||
@property
|
||||
def size_text(self) -> str:
|
||||
return f"{self.size / 1e9:.1f} GB" if self.size else "no card"
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
vols = ", ".join(f"{v.mount or '-'} {v.filesystem} '{v.label}'" for v in self.volumes) or "no volume"
|
||||
return f"Disk {self.id}: {self.name} - {self.size_text} ({self.bus}) - {vols}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Windows
|
||||
|
||||
WIN_LIST_PS = r"""
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
# Filter by bus BEFORE touching partitions: querying volumes of every
|
||||
# internal disk made this take >30 s on a machine with several HDDs.
|
||||
$out = @(Get-Disk | Where-Object { -not $_.IsSystem -and -not $_.IsBoot -and
|
||||
@('USB','SD','MMC') -contains [string]$_.BusType } | ForEach-Object {
|
||||
$d = $_
|
||||
$vols = @(Get-Partition -DiskNumber $d.Number | ForEach-Object {
|
||||
$v = $_ | Get-Volume
|
||||
[pscustomobject]@{ DriveLetter = [string]$_.DriveLetter; FileSystem = [string]$v.FileSystem; Label = [string]$v.FileSystemLabel }
|
||||
})
|
||||
[pscustomobject]@{ Number = $d.Number; FriendlyName = $d.FriendlyName; Size = [int64]$d.Size;
|
||||
BusType = [string]$d.BusType; Volumes = $vols }
|
||||
})
|
||||
ConvertTo-Json -InputObject $out -Depth 4 -Compress
|
||||
"""
|
||||
|
||||
|
||||
def parse_windows(json_text: str) -> list[Disk]:
|
||||
data = json.loads(json_text or "[]")
|
||||
if isinstance(data, dict): # ConvertTo-Json quirk for one element
|
||||
data = [data]
|
||||
disks = []
|
||||
for d in data:
|
||||
if str(d.get("BusType")) not in CARD_BUSES:
|
||||
continue
|
||||
vols = d.get("Volumes") or []
|
||||
if isinstance(vols, dict):
|
||||
vols = [vols]
|
||||
volumes = [Volume(f"{v['DriveLetter']}:\\" if v.get("DriveLetter") and v["DriveLetter"] not in ("\x00", "") else "",
|
||||
v.get("FileSystem") or "", v.get("Label") or "") for v in vols]
|
||||
size = int(d.get("Size") or 0)
|
||||
disks.append(Disk(str(d["Number"]), d.get("FriendlyName") or "Disk", size, str(d["BusType"]),
|
||||
[v for v in volumes if v.mount or v.filesystem], has_media=size > 0))
|
||||
return disks
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- macOS
|
||||
|
||||
def parse_macos(list_plist: bytes, infos: dict[str, bytes]) -> list[Disk]:
|
||||
"""`diskutil list -plist physical` + `diskutil info -plist <disk>` for each."""
|
||||
listing = plistlib.loads(list_plist)
|
||||
disks = []
|
||||
for entry in listing.get("AllDisksAndPartitions", []):
|
||||
ident = entry.get("DeviceIdentifier", "")
|
||||
info = plistlib.loads(infos[ident]) if ident in infos else {}
|
||||
protocol = str(info.get("BusProtocol", ""))
|
||||
removable = bool(info.get("RemovableMedia") or info.get("Removable") or info.get("Ejectable"))
|
||||
internal = bool(info.get("Internal", True))
|
||||
is_root = any(p.get("MountPoint") == "/" for p in entry.get("Partitions", []) + entry.get("APFSVolumes", []))
|
||||
if ident == "disk0" or is_root or not (removable or not internal or protocol in ("USB", "Secure Digital", "SD")):
|
||||
continue
|
||||
vols = [Volume(p.get("MountPoint", ""), p.get("Content", ""), p.get("VolumeName", ""))
|
||||
for p in entry.get("Partitions", [])]
|
||||
size = int(entry.get("Size") or info.get("TotalSize") or 0)
|
||||
bus = "SD" if "Secure Digital" in protocol or protocol == "SD" else (protocol or "USB")
|
||||
disks.append(Disk(f"/dev/{ident}", info.get("MediaName", "Disk"), size, bus, vols, has_media=size > 0))
|
||||
return disks
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ public
|
||||
|
||||
def list_cards() -> tuple[list[Disk], list[Disk]]:
|
||||
"""(cards that may be formatted, readers/disks shown but not offered)."""
|
||||
if sys.platform == "win32":
|
||||
# Driver-level listing (milliseconds, 1.5 s worst case) instead of
|
||||
# Get-Disk/WMI, which stalled 5-21 s on an empty card-reader slot.
|
||||
from .win_disks import list_disks
|
||||
disks = list_disks(CARD_BUSES)
|
||||
elif sys.platform == "darwin":
|
||||
lst = subprocess.run(["diskutil", "list", "-plist", "physical"], capture_output=True, timeout=30).stdout
|
||||
infos = {}
|
||||
for e in plistlib.loads(lst).get("AllDisksAndPartitions", []):
|
||||
ident = e.get("DeviceIdentifier", "")
|
||||
infos[ident] = subprocess.run(["diskutil", "info", "-plist", ident], capture_output=True, timeout=30).stdout
|
||||
disks = parse_macos(lst, infos)
|
||||
else:
|
||||
return [], []
|
||||
ok = [d for d in disks if d.has_media and 0 < d.size <= MAX_CARD_BYTES]
|
||||
return ok, [d for d in disks if d not in ok]
|
||||
@@ -0,0 +1,151 @@
|
||||
"""ENV.TXT for the sensorbox SD card.
|
||||
|
||||
Parsing mirrors EnvironmentService::read_env_var in the firmware exactly:
|
||||
lines read with a 512-byte buffer, split at the FIRST '=', key compared
|
||||
verbatim (no trimming), CR/LF removed from the value only, first match wins.
|
||||
So the checks below report what would actually break a box - e.g. a UTF-8
|
||||
BOM (Windows Notepad) hides the first key, and "KEY = value" is not "KEY".
|
||||
|
||||
The firmware FAT driver has no long-file-name support: the file must be
|
||||
the 8.3 name ENV.TXT (case-insensitive) in the card root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ENV_NAME = "ENV.TXT"
|
||||
BUFFER_NAME = "BUFFER.TXT" # offline measurements waiting for upload
|
||||
LINE_BUF = 512 # char line[512] in the firmware
|
||||
REQUIRED = ("INFLUX_URL", "INFLUX_TOKEN")
|
||||
OPTIONAL = ("SSID", "PASSWORD")
|
||||
_BOM = b"\xef\xbb\xbf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
level: str # "error" | "warning" | "info"
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvConfig:
|
||||
influx_url: str = ""
|
||||
influx_token: str = ""
|
||||
ssid: str = ""
|
||||
password: str = ""
|
||||
|
||||
@property
|
||||
def local_wifi(self) -> bool:
|
||||
return bool(self.ssid or self.password)
|
||||
|
||||
def lines(self) -> list[tuple[str, str]]:
|
||||
out = [("INFLUX_TOKEN", self.influx_token), ("INFLUX_URL", self.influx_url)]
|
||||
if self.local_wifi:
|
||||
out += [("SSID", self.ssid), ("PASSWORD", self.password)]
|
||||
return out
|
||||
|
||||
def render(self) -> bytes:
|
||||
# LF only, no BOM, no quoting: exactly what the firmware parser expects.
|
||||
return "".join(f"{k}={v}\n" for k, v in self.lines()).encode("utf-8")
|
||||
|
||||
def problems(self) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
for key, value in self.lines():
|
||||
if any(c in value for c in "\r\n"):
|
||||
issues.append(Issue("error", f"{key} contains a line break."))
|
||||
if value != value.strip():
|
||||
issues.append(Issue("error", f"{key} starts or ends with a space; the box would use the space too."))
|
||||
if len(f"{key}={value}".encode()) >= LINE_BUF - 1:
|
||||
issues.append(Issue("error", f"{key} is too long for the box (max {LINE_BUF - 2} bytes per line)."))
|
||||
url = self.influx_url.strip()
|
||||
if not url:
|
||||
issues.append(Issue("error", "INFLUX_URL is empty."))
|
||||
elif not re.match(r"^https?://", url):
|
||||
issues.append(Issue("error", "INFLUX_URL must start with https://"))
|
||||
elif not ("/api/v2/write" in url and "bucket=" in url and "org=" in url):
|
||||
issues.append(Issue("error", "INFLUX_URL must be a write URL: .../api/v2/write?bucket=...&org=..."))
|
||||
elif url.startswith("http://"):
|
||||
issues.append(Issue("warning", "INFLUX_URL uses http:// (unencrypted)."))
|
||||
if not self.influx_token:
|
||||
issues.append(Issue("error", "INFLUX_TOKEN is empty."))
|
||||
elif re.search(r"\s", self.influx_token):
|
||||
issues.append(Issue("error", "INFLUX_TOKEN contains spaces."))
|
||||
elif len(self.influx_token) < 20:
|
||||
issues.append(Issue("warning", "INFLUX_TOKEN looks too short."))
|
||||
if self.local_wifi:
|
||||
if not self.ssid or not self.password:
|
||||
issues.append(Issue("error", "Wi-Fi needs both SSID and PASSWORD (the box ignores one without the other)."))
|
||||
if len(self.ssid.encode()) > 32:
|
||||
issues.append(Issue("error", "SSID is longer than 32 bytes."))
|
||||
pw = self.password
|
||||
if pw and not (8 <= len(pw) <= 63 or re.fullmatch(r"[0-9A-Fa-f]{64}", pw)):
|
||||
issues.append(Issue("error", "Wi-Fi PASSWORD must be 8-63 characters (WPA2)."))
|
||||
return issues
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvCheck:
|
||||
values: dict[str, str]
|
||||
issues: list[Issue]
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not any(i.level == "error" for i in self.issues)
|
||||
|
||||
def masked(self) -> dict[str, str]:
|
||||
out = {}
|
||||
for k, v in self.values.items():
|
||||
out[k] = (v[:4] + "..." + v[-4:] if len(v) > 12 else "***") if k in ("INFLUX_TOKEN", "PASSWORD") else v
|
||||
return out
|
||||
|
||||
|
||||
def parse_env(raw: bytes) -> EnvCheck:
|
||||
"""Read ENV.TXT the way the firmware does and report what it would see."""
|
||||
issues: list[Issue] = []
|
||||
if raw.startswith(_BOM):
|
||||
issues.append(Issue("error", "File starts with a UTF-8 BOM (saved by Notepad?): the box cannot read the first key. "
|
||||
"Re-write the card with the tool."))
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
values: dict[str, str] = {}
|
||||
for n, line in enumerate(text.split("\n"), 1):
|
||||
if len(line.encode()) >= LINE_BUF - 1:
|
||||
issues.append(Issue("error", f"Line {n} is longer than the box can read."))
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
value = value.replace("\r", "")
|
||||
if key != key.strip():
|
||||
issues.append(Issue("error", f"Line {n}: spaces around the key '{key.strip()}' - the box will not find it."))
|
||||
if key in values:
|
||||
issues.append(Issue("warning", f"{key} appears twice; the box uses the first one."))
|
||||
continue
|
||||
values[key] = value
|
||||
for key in REQUIRED:
|
||||
if key not in values:
|
||||
issues.append(Issue("error", f"{key} is missing - the box stops at start-up (white LED)."))
|
||||
elif not values[key]:
|
||||
issues.append(Issue("error", f"{key} is empty."))
|
||||
if values.get("HTTP_SERVER") == "true":
|
||||
issues.append(Issue("warning", "HTTP_SERVER=true: a box with newer firmware starts in Wi-Fi setup mode."))
|
||||
if bool(values.get("SSID")) != bool(values.get("PASSWORD")):
|
||||
issues.append(Issue("warning", "Only one of SSID / PASSWORD is set; the box ignores Wi-Fi settings."))
|
||||
elif values.get("SSID"):
|
||||
issues.append(Issue("info", "Wi-Fi settings present: newer firmware uses this Wi-Fi instead of Matter "
|
||||
"(the factory v1.0 firmware ignores them)."))
|
||||
return EnvCheck(values, issues)
|
||||
|
||||
|
||||
def load_template(path: Path) -> EnvConfig:
|
||||
"""Settings from an existing ENV.TXT (e.g. 002-SD_card_format/ENV.txt)."""
|
||||
v = parse_env(path.read_bytes()).values
|
||||
return EnvConfig(v.get("INFLUX_URL", ""), v.get("INFLUX_TOKEN", ""), v.get("SSID", ""), v.get("PASSWORD", ""))
|
||||
|
||||
|
||||
def find_case_insensitive(root: Path, name: str) -> Path | None:
|
||||
for p in root.iterdir() if root.is_dir() else ():
|
||||
if p.is_file() and p.name.lower() == name.lower():
|
||||
return p
|
||||
return None
|
||||
@@ -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
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Format an SD card for the sensorbox and write / check ENV.TXT.
|
||||
|
||||
Card format required by the firmware: MBR partition table, FAT32 (its FAT
|
||||
driver has no exFAT and no long file names), ENV.TXT in the root.
|
||||
|
||||
Windows: formatting needs administrator rights, so kota_tech.sd.win_format
|
||||
runs elevated (UAC prompt). It re-checks the disk (number, size, bus, not the
|
||||
Windows drive) right before erasing it, so a card swapped after the
|
||||
technician confirmed is not touched, and it writes MBR + FAT32 itself
|
||||
(no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
|
||||
get a 32 GB FAT32 partition (Windows convention; plenty for the box).
|
||||
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .disks import Disk
|
||||
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
|
||||
|
||||
VOLUME_LABEL = "KOTA"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Outcome:
|
||||
ok: bool
|
||||
message: str
|
||||
mount: str = ""
|
||||
log: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardReport:
|
||||
mount: str
|
||||
filesystem: str
|
||||
env: EnvCheck | None
|
||||
buffered_lines: int | None # measurements waiting on the card for upload
|
||||
problems: list[str]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- Windows
|
||||
|
||||
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
|
||||
|
||||
|
||||
def _workdir() -> Path:
|
||||
# Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a
|
||||
# file written there by the ELEVATED script is then unreadable for this
|
||||
# (non-elevated) process. A normally inherited ACL works for both.
|
||||
work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}"
|
||||
work.mkdir(parents=True, exist_ok=False)
|
||||
return work
|
||||
|
||||
|
||||
def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
|
||||
"""(program, parameters, working dir) for the elevated helper."""
|
||||
root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/
|
||||
params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
|
||||
f'--label {VOLUME_LABEL} --result "{result}"'
|
||||
return sys.executable, params, str(root)
|
||||
|
||||
|
||||
def _run_elevated(program: str, params: str, cwd: str, timeout_s: int = 300) -> int | None:
|
||||
"""ShellExecuteEx 'runas' + wait. None if the technician refused the UAC prompt."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class SHELLEXECUTEINFOW(ctypes.Structure):
|
||||
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
|
||||
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
|
||||
("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
|
||||
("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
|
||||
("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
|
||||
|
||||
SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
|
||||
info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
|
||||
lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
|
||||
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
if not shell32.ShellExecuteExW(ctypes.byref(info)):
|
||||
return None # ERROR_CANCELLED (1223) or failure
|
||||
k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
|
||||
code = wintypes.DWORD()
|
||||
k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
|
||||
k32.CloseHandle(info.hProcess)
|
||||
return code.value
|
||||
|
||||
|
||||
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
|
||||
result = _workdir() / "result.json"
|
||||
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
|
||||
t = time.perf_counter()
|
||||
code = _run_elevated(*win_format_command(disk, result))
|
||||
if code is None:
|
||||
return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
|
||||
try:
|
||||
res = json.loads(result.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
|
||||
"Check the card with 'Check card'.", log=log)
|
||||
steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items())
|
||||
log.append(f"{res.get('message', '')} ({time.perf_counter() - t:.1f} s incl. permission prompt; {steps})")
|
||||
return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
|
||||
|
||||
|
||||
def _eject_windows(mount: str) -> bool:
|
||||
"""Flush, lock, dismount and eject the card - what 'Safely Remove' does.
|
||||
|
||||
Not Shell.Application InvokeVerb('Eject'): it fails silently because the
|
||||
verb is named 'E&ject' (and is localised on non-English Windows).
|
||||
"""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
k32.CreateFileW.restype = wintypes.HANDLE
|
||||
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
|
||||
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
||||
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
|
||||
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||||
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
|
||||
FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020
|
||||
IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808
|
||||
handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
|
||||
if handle in (None, wintypes.HANDLE(-1).value):
|
||||
return False
|
||||
ret = wintypes.DWORD()
|
||||
|
||||
def ioctl(code: int, buf=None, size: int = 0) -> bool:
|
||||
return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None))
|
||||
|
||||
try:
|
||||
k32.FlushFileBuffers(handle)
|
||||
for _ in range(10): # Explorer/indexer may hold it briefly
|
||||
if ioctl(FSCTL_LOCK_VOLUME):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
return False
|
||||
if not ioctl(FSCTL_DISMOUNT_VOLUME):
|
||||
return False
|
||||
allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE}
|
||||
ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1)
|
||||
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices
|
||||
return True
|
||||
finally:
|
||||
k32.CloseHandle(handle)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- macOS
|
||||
|
||||
def _format_macos(disk: Disk, log: list[str]) -> Outcome:
|
||||
log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}")
|
||||
proc = subprocess.run(["diskutil", "eraseDisk", "FAT32", VOLUME_LABEL, "MBRFormat", disk.id],
|
||||
capture_output=True, text=True, timeout=600)
|
||||
log += [l for l in proc.stdout.splitlines() if l.strip()]
|
||||
if proc.returncode != 0:
|
||||
return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
|
||||
+ " (is the card's write-protect switch on?)", log=log)
|
||||
part = f"{disk.id}s1"
|
||||
for _ in range(20): # wait for the volume to mount
|
||||
info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"<plist><dict/></plist>")
|
||||
if info.get("MountPoint"):
|
||||
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log)
|
||||
time.sleep(0.5)
|
||||
return Outcome(False, "Formatted, but the card did not mount.", log=log)
|
||||
|
||||
|
||||
def _eject_macos(disk_id: str) -> bool:
|
||||
return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ common
|
||||
|
||||
def write_env(mount: str, env: EnvConfig) -> Outcome:
|
||||
"""Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back."""
|
||||
problems = [i.text for i in env.problems() if i.level == "error"]
|
||||
if problems:
|
||||
return Outcome(False, "Settings are not valid: " + " ".join(problems))
|
||||
root = Path(mount)
|
||||
if not root.is_dir():
|
||||
return Outcome(False, f"Card not found at {mount}.")
|
||||
old = find_case_insensitive(root, ENV_NAME)
|
||||
if old is not None and old.name != ENV_NAME:
|
||||
old.unlink() # replace env.txt / Env.txt variants
|
||||
target = root / ENV_NAME
|
||||
data = env.render()
|
||||
with open(target, "wb") as fh:
|
||||
fh.write(data)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
if target.read_bytes() != data:
|
||||
return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.")
|
||||
check = parse_env(target.read_bytes())
|
||||
if not check.ok:
|
||||
return Outcome(False, "ENV.TXT was written but does not validate: "
|
||||
+ " ".join(i.text for i in check.issues if i.level == "error"))
|
||||
return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount)
|
||||
|
||||
|
||||
def format_and_write(disk: Disk, env: EnvConfig, eject_card: bool = True) -> Outcome:
|
||||
problems = [i.text for i in env.problems() if i.level == "error"]
|
||||
if problems:
|
||||
return Outcome(False, "Settings are not valid: " + " ".join(problems))
|
||||
log: list[str] = []
|
||||
if sys.platform == "win32":
|
||||
fmt = _format_windows(disk, log)
|
||||
elif sys.platform == "darwin":
|
||||
fmt = _format_macos(disk, log)
|
||||
else:
|
||||
return Outcome(False, "SD provisioning is supported on Windows and macOS only.")
|
||||
if not fmt.ok:
|
||||
return fmt
|
||||
wr = write_env(fmt.mount, env)
|
||||
log.append(wr.message)
|
||||
if not wr.ok:
|
||||
return Outcome(False, wr.message, fmt.mount, log)
|
||||
if eject_card:
|
||||
log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else
|
||||
"Could not eject the card automatically - use 'Safely remove' before pulling it out.")
|
||||
return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log)
|
||||
|
||||
|
||||
def eject(mount: str, disk: Disk | None = None) -> bool:
|
||||
"""True if the card was dismounted and can be pulled out safely."""
|
||||
if sys.platform == "win32":
|
||||
return _eject_windows(mount)
|
||||
if sys.platform == "darwin" and disk is not None:
|
||||
return _eject_macos(disk.id)
|
||||
return False
|
||||
|
||||
|
||||
def check_card(mount: str, filesystem: str = "") -> CardReport:
|
||||
"""What the box would find on this card (values masked in the UI)."""
|
||||
root = Path(mount)
|
||||
problems: list[str] = []
|
||||
fs = filesystem.upper()
|
||||
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"):
|
||||
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
|
||||
env_file = find_case_insensitive(root, ENV_NAME)
|
||||
env = parse_env(env_file.read_bytes()) if env_file else None
|
||||
if env_file is None:
|
||||
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
|
||||
elif len(env_file.name.split(".")[0]) > 8:
|
||||
problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
|
||||
buf = find_case_insensitive(root, BUFFER_NAME)
|
||||
lines = None
|
||||
if buf is not None:
|
||||
with open(buf, "rb") as fh:
|
||||
lines = sum(1 for ln in fh if ln.strip())
|
||||
return CardReport(mount, filesystem, env, lines, problems)
|
||||
@@ -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())
|
||||
Whitespace-only changes.
@@ -0,0 +1,101 @@
|
||||
"""Hand-off of a diagnosis to the KOTA service system (Google Apps Script).
|
||||
|
||||
The tool opens the box's service page in the technician's browser:
|
||||
<base><MAC>#kota-report=<base64url(JSON)>
|
||||
The service page (v1.1.0+) reads the fragment and pre-fills status and note;
|
||||
the technician checks it, adds the location and saves - so the report is
|
||||
authored by their own Google login and the tool holds no credentials.
|
||||
The fragment is never sent to any server (redirect host or Apps Script).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
from .. import __version__
|
||||
from ..diag.engine import Report, Severity
|
||||
|
||||
DEFAULT_BASE = "https://service.kota-app.com/"
|
||||
NOTE_LIMIT = 4000 # the service form accepts 5000; leave room for the technician
|
||||
|
||||
# Status names exactly as in the service system (Code.gs STATUSES).
|
||||
OK = "Działa poprawnie"
|
||||
CHECK = "Do sprawdzenia"
|
||||
IN_SERVICE = "W trakcie serwisu"
|
||||
HARDWARE = "Błąd hardware"
|
||||
SOFTWARE = "Błąd software"
|
||||
POWER = "Problem z zasilaniem"
|
||||
CONNECTIVITY = "Problem z łącznością"
|
||||
|
||||
# Root-finding rule id prefix -> service status (first match wins).
|
||||
_STATUS_BY_RULE = [
|
||||
("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER),
|
||||
("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE),
|
||||
("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
|
||||
("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
|
||||
("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE),
|
||||
("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE),
|
||||
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
|
||||
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
|
||||
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
|
||||
("rtc.clock_not_running", CHECK), ("reboot.", CHECK),
|
||||
]
|
||||
_MAC = re.compile(r"^[0-9A-F]{12}$")
|
||||
|
||||
|
||||
def normalize_mac(mac: str) -> str:
|
||||
return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper()
|
||||
|
||||
|
||||
def service_status(report: Report) -> str:
|
||||
"""Suggested status; the technician can change it in the form."""
|
||||
problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING]
|
||||
if not problems:
|
||||
return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK
|
||||
rid = problems[0].finding.rule_id
|
||||
for prefix, status in _STATUS_BY_RULE:
|
||||
if rid.startswith(prefix):
|
||||
return status
|
||||
return CHECK
|
||||
|
||||
|
||||
def service_note(report: Report, limit: int = NOTE_LIMIT) -> str:
|
||||
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}",
|
||||
f"Firmware: {report.firmware.describe()}",
|
||||
f"Expected LED: {report.led.capitalize()}"]
|
||||
reached = [label for _, label, t in report.milestones if t is not None]
|
||||
missing = [label for _, label, t in report.milestones if t is None]
|
||||
if reached:
|
||||
lines.append("OK: " + ", ".join(reached))
|
||||
if missing:
|
||||
lines.append("Not reached: " + ", ".join(missing))
|
||||
for rc in report.roots:
|
||||
f = rc.finding
|
||||
lines.append(f"- [{f.severity.label}] {f.title}")
|
||||
for c in rc.consequences:
|
||||
lines.append(f" -> {c.title}")
|
||||
if report.technician:
|
||||
lines.append("Technician checks:")
|
||||
lines += [f" * {t}" for t in report.technician[-12:]]
|
||||
text = "\n".join(lines)
|
||||
return text if len(text) <= limit else text[:limit - 20].rstrip() + "\n...(shortened)"
|
||||
|
||||
|
||||
def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str:
|
||||
mac = normalize_mac(mac)
|
||||
if not _MAC.match(mac):
|
||||
raise ValueError(f"not a box MAC address: {mac!r}")
|
||||
payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report),
|
||||
"tool": f"v{__version__}"}
|
||||
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
base = base if base.endswith("/") else base + "/"
|
||||
return f"{base}{mac}#kota-report={frag}"
|
||||
|
||||
|
||||
def decode_fragment(url: str) -> dict:
|
||||
"""Inverse of report_url (used by tests and for troubleshooting)."""
|
||||
frag = url.split("#kota-report=", 1)[1]
|
||||
return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8"))
|
||||
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextBrowser,
|
||||
@@ -99,6 +100,9 @@ class DiagnosticsPanel(QWidget):
|
||||
lay.addLayout(led_row)
|
||||
lay.addWidget(_heading("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(self.milestones)
|
||||
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
||||
|
||||
+115
-26
@@ -1,4 +1,4 @@
|
||||
"""Flash firmware tab: official image only, App-only by default."""
|
||||
"""Flash firmware tab: factory image or imported developer builds; App-only by default."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +10,8 @@ from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
@@ -24,7 +26,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..core.session import SESSIONS_DIR
|
||||
from ..flash.image import FirmwareImage, ImageError, official_images
|
||||
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, bundled_images
|
||||
from ..flash.plan import Mode, build_plan
|
||||
from ..flash.runner import FlashRunner, Result
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
@@ -71,13 +73,26 @@ class FlashTab(QWidget):
|
||||
bl.addWidget(self.box_fw)
|
||||
|
||||
# 2. firmware
|
||||
fwbox = QGroupBox("2. Firmware (official images only)")
|
||||
fwbox = QGroupBox("2. Firmware")
|
||||
self.image_combo = QComboBox(minimumWidth=360)
|
||||
self.import_btn = QPushButton("Import firmware file...")
|
||||
self.import_btn.setToolTip("Add a newer build the firmware team sent you (.bin)")
|
||||
self.import_btn.clicked.connect(self._import)
|
||||
self.remove_btn = QPushButton("Remove from list")
|
||||
self.remove_btn.clicked.connect(self._remove)
|
||||
self.image_info = QLabel(wordWrap=True)
|
||||
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
fl = QVBoxLayout(fwbox)
|
||||
fl.addWidget(self.image_combo)
|
||||
brow = QHBoxLayout()
|
||||
brow.addWidget(self.import_btn)
|
||||
brow.addWidget(self.remove_btn)
|
||||
brow.addStretch(1)
|
||||
fl.addLayout(brow)
|
||||
fl.addWidget(self.image_info)
|
||||
self._library = FirmwareLibrary()
|
||||
self._reference: FirmwareImage | None = None
|
||||
self._problems: list[str] = []
|
||||
|
||||
# 3. mode
|
||||
modebox = QGroupBox("3. What to write")
|
||||
@@ -151,17 +166,61 @@ class FlashTab(QWidget):
|
||||
self._show_result("", None)
|
||||
|
||||
# ------------------------------------------------------------ setup
|
||||
def _load_images(self) -> None:
|
||||
def _load_images(self, select_sha: str | None = None) -> None:
|
||||
self._problems = []
|
||||
try:
|
||||
self._images = official_images()
|
||||
bundled = bundled_images()
|
||||
except ImageError as exc:
|
||||
self._images = []
|
||||
self.image_info.setText(f"<span style='color:#b03a2e'><b>Firmware file problem:</b> {exc}</span>")
|
||||
bundled = []
|
||||
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
|
||||
dev, problems = self._library.images(self._reference)
|
||||
dev += [i for i in bundled if i.is_developer]
|
||||
self._problems += problems
|
||||
# Newest developer builds first, then the factory image.
|
||||
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True)
|
||||
self._images = dev + factory
|
||||
self.image_combo.blockSignals(True)
|
||||
self.image_combo.clear()
|
||||
for img in self._images:
|
||||
self.image_combo.addItem(img.name)
|
||||
self.image_combo.addItem(img.label())
|
||||
if not self._images:
|
||||
self.image_combo.addItem("No official firmware image found in the firmware folder")
|
||||
self.image_combo.addItem("No firmware image available - import one")
|
||||
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), None)
|
||||
if idx is None:
|
||||
idx = len(dev) if factory else 0 # default: the factory image
|
||||
self.image_combo.setCurrentIndex(idx)
|
||||
self.image_combo.blockSignals(False)
|
||||
self._update()
|
||||
|
||||
def _import(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Import firmware from the firmware team", str(Path.home()),
|
||||
"Firmware (*.bin);;All files (*)")
|
||||
if not path:
|
||||
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:
|
||||
img = self._library.import_file(Path(path), self._reference, built_by=built_by)
|
||||
except (ImageError, OSError) as exc:
|
||||
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}")
|
||||
return
|
||||
self._load_images(select_sha=img.sha256)
|
||||
kind = "complete image" if img.kind == "merged" else "app only"
|
||||
QMessageBox.information(
|
||||
self, "Firmware imported",
|
||||
f"{img.name}\nbuild {img.app.elf_sha[:9]}, {kind}\n\nFile fingerprint (SHA-256):\n{img.sha256}\n\n"
|
||||
"Compare it with the fingerprint the firmware team sent you before flashing.")
|
||||
|
||||
def _remove(self) -> None:
|
||||
img = self._selected_image()
|
||||
if img and img.is_developer and QMessageBox.question(
|
||||
self, "Remove firmware", f"Remove {img.name} from this computer?") == QMessageBox.StandardButton.Yes:
|
||||
self._library.remove(img.sha256)
|
||||
self._load_images()
|
||||
|
||||
def refresh_ports(self) -> None:
|
||||
current = self.port_combo.currentData()
|
||||
@@ -189,24 +248,27 @@ class FlashTab(QWidget):
|
||||
|
||||
def _update(self) -> None:
|
||||
img = self._selected_image()
|
||||
info = ""
|
||||
if img:
|
||||
a = img.app
|
||||
self.image_info.setText(
|
||||
f"{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}<br>"
|
||||
f"<small>build {a.elf_sha[:9]} · file SHA-256 {img.sha256[:12]}... verified</small><br>"
|
||||
f"<small>{img.notes}</small>")
|
||||
fw = self.serial_tab.engine.firmware
|
||||
if fw.elf_sha and img:
|
||||
same = fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha)
|
||||
msg = f"Box currently runs: {fw.describe()}"
|
||||
if same:
|
||||
msg += "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
|
||||
else:
|
||||
msg += ("<br><b style='color:#c77c02'>The box runs a different build (probably updated over OTA). "
|
||||
"Flashing puts the older factory image back; the box updates itself again once online.</b>")
|
||||
else:
|
||||
msg = "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
|
||||
self.box_fw.setText(msg)
|
||||
origin = ("<b>Factory image</b> (bundled with the tool)" if not img.is_developer else
|
||||
"<b style='color:#c77c02'>Developer build</b> - only flash builds you received "
|
||||
"from the firmware team")
|
||||
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
|
||||
info = (f"{origin}<br>{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}, {kind}<br>"
|
||||
f"<small>build {a.elf_sha[:9]} · file SHA-256 {img.sha256[:16]}... "
|
||||
f"· checksum OK</small>" + (f"<br><small>{img.notes}</small>" if img.notes else ""))
|
||||
if self._problems:
|
||||
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
|
||||
+ "; ".join(self._problems) + "</span>")
|
||||
self.image_info.setText(info)
|
||||
self.remove_btn.setEnabled(bool(img and img.is_developer) and self._runner is None)
|
||||
# An app-only file has no bootloader/layout: Full mode is impossible.
|
||||
full_ok = bool(img and img.kind == "merged")
|
||||
self.rb_full.setEnabled(full_ok and self._runner is None)
|
||||
if not full_ok and self.rb_full.isChecked():
|
||||
self.rb_app.setChecked(True)
|
||||
self.box_fw.setText(self._box_text(img))
|
||||
|
||||
self.confirm_full.setVisible(self.rb_full.isChecked())
|
||||
ok = bool(img and self._selected_port() and self._runner is None
|
||||
@@ -224,6 +286,23 @@ class FlashTab(QWidget):
|
||||
else:
|
||||
self.plan_label.setText("Select a sensorbox and a firmware image.")
|
||||
|
||||
def _box_text(self, img: FirmwareImage | None) -> str:
|
||||
fw = self.serial_tab.engine.firmware
|
||||
if not fw.elf_sha:
|
||||
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
|
||||
msg = f"Box currently runs: {fw.describe()}" + (f", built {fw.built}" if fw.built else "")
|
||||
if not img:
|
||||
return msg
|
||||
if fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha):
|
||||
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
|
||||
box_built, img_built = _parse_built(fw.built), img.app.built_at
|
||||
if box_built and img_built and img_built > box_built:
|
||||
return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>"
|
||||
if box_built and img_built and img_built < box_built:
|
||||
return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was "
|
||||
"probably updated over OTA). The box updates itself again once online.</b>")
|
||||
return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>"
|
||||
|
||||
# ------------------------------------------------------------ flash
|
||||
def _start(self) -> None:
|
||||
img, port = self._selected_image(), self._selected_port()
|
||||
@@ -256,7 +335,7 @@ class FlashTab(QWidget):
|
||||
self._runner.start()
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
for w in (self.port_combo, self.image_combo, self.rb_app, self.rb_full, self.backup, self.confirm_full):
|
||||
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full, self.import_btn):
|
||||
w.setEnabled(not busy)
|
||||
self._update()
|
||||
|
||||
@@ -307,3 +386,13 @@ class FlashTab(QWidget):
|
||||
border, bg = colours[kind or "info"]
|
||||
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
||||
self.result.setText(html)
|
||||
|
||||
|
||||
def _parse_built(s: str | None) -> dt.datetime | None:
|
||||
"""'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -18,6 +18,8 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ..diag.engine import LED, DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner, recommended
|
||||
from ..matter.payload import SetupPayload
|
||||
from .matter_qr import MatterQrWidget
|
||||
|
||||
END_STYLE = { # end severity -> (border, background)
|
||||
"ok": ("#2e9e44", "#e3f4e7"),
|
||||
@@ -52,9 +54,11 @@ def _esc(s: str) -> str:
|
||||
|
||||
|
||||
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)
|
||||
self.runner, self.engine = runner, engine
|
||||
self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
|
||||
self._shown_key: tuple | None = None
|
||||
|
||||
# --- picker (no guide running)
|
||||
@@ -93,7 +97,8 @@ class GuidePanel(QWidget):
|
||||
self.end_box = QLabel(wordWrap=True)
|
||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||
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.addLayout(self.options)
|
||||
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
|
||||
self.step_text.setVisible(not is_end)
|
||||
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:
|
||||
b = QPushButton(label)
|
||||
b.setMinimumHeight(34)
|
||||
|
||||
@@ -2,15 +2,17 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
@@ -28,10 +30,14 @@ from ..diag.engine import DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner
|
||||
from ..serialio.manager import SerialManager, State
|
||||
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 .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
from .flash_tab import FlashTab
|
||||
from .matter_qr import MatterQrDialog
|
||||
from .guide_panel import GuidePanel
|
||||
from .sd_tab import SdTab
|
||||
|
||||
MAX_LINES = 20000
|
||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||
@@ -84,6 +90,14 @@ class SerialTab(QWidget):
|
||||
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
|
||||
self.copy_btn = QPushButton("Copy report")
|
||||
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
|
||||
self.service_btn = QPushButton("Service report...")
|
||||
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
|
||||
"filled in; check it and press Save there")
|
||||
self._settings = QSettings("KOTA", "TechnicianTool")
|
||||
self.auto_service = QCheckBox("Open service report on disconnect")
|
||||
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
|
||||
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
|
||||
self._active_port: PortInfo | None = None
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
||||
@@ -92,7 +106,7 @@ class SerialTab(QWidget):
|
||||
row1.addStretch(1)
|
||||
row2 = QHBoxLayout()
|
||||
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
|
||||
self.analyze_btn, self.copy_btn):
|
||||
self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service):
|
||||
row2.addWidget(w)
|
||||
row2.addStretch(1)
|
||||
|
||||
@@ -106,7 +120,7 @@ class SerialTab(QWidget):
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
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.addTab(self.diag, "Diagnosis")
|
||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||
@@ -134,6 +148,8 @@ class SerialTab(QWidget):
|
||||
self.folder_btn.clicked.connect(self._open_folder)
|
||||
self.analyze_btn.clicked.connect(self._analyze_file)
|
||||
self.copy_btn.clicked.connect(self._copy_report)
|
||||
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.
|
||||
self._diag_timer = QTimer(self, interval=400)
|
||||
@@ -189,8 +205,29 @@ class SerialTab(QWidget):
|
||||
self._runner.cancel()
|
||||
self._diag_dirty = True
|
||||
self._session.start(port.mac or port.device)
|
||||
self._active_port = 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 -------------------------------------------------
|
||||
def _service_base(self) -> str:
|
||||
return str(self._settings.value("service/base", DEFAULT_BASE))
|
||||
|
||||
def _open_service_report(self, auto: bool) -> None:
|
||||
mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "")
|
||||
if len(mac) != 12:
|
||||
if not auto:
|
||||
QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. "
|
||||
"Connect to the box and press Reset & capture first.")
|
||||
return
|
||||
url = report_url(mac, self._engine.report(), self._service_base())
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
# Log that it happened, not the report itself (it is already in the session log).
|
||||
self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there")
|
||||
|
||||
# ---- hand-off with the flash tab ------------------------------------
|
||||
@property
|
||||
def engine(self) -> DiagnosticsEngine:
|
||||
@@ -199,8 +236,10 @@ class SerialTab(QWidget):
|
||||
def release_port(self) -> None:
|
||||
"""Stop monitoring so esptool can open the port exclusively."""
|
||||
if self._manager.is_running:
|
||||
self._no_auto_service = True # a flash hand-off is not the end of a session
|
||||
self._manager.disconnect() # joins the reader thread
|
||||
QCoreApplication.processEvents() # deliver its queued final lines/state now
|
||||
self._no_auto_service = False
|
||||
|
||||
def connect_port(self, port: PortInfo, capture: bool = True) -> None:
|
||||
"""Monitor `port`; with capture, restart the box once connected to record its boot."""
|
||||
@@ -223,6 +262,9 @@ class SerialTab(QWidget):
|
||||
QTimer.singleShot(300, self._manager.reset_board)
|
||||
if not running and self._session.path is not None:
|
||||
self._session.write_report(self._engine.report().to_text())
|
||||
if (self.auto_service.isChecked() and self._engine.lines
|
||||
and not getattr(self, "_no_auto_service", False)):
|
||||
self._open_service_report(auto=True)
|
||||
self._session.stop()
|
||||
self.connect_btn.setText("Disconnect" if running else "Connect")
|
||||
self.reset_btn.setEnabled(state == State.CONNECTED)
|
||||
@@ -351,7 +393,22 @@ class MainWindow(QMainWindow):
|
||||
tabs.addTab(guide, "Quick start")
|
||||
tabs.addTab(self.serial_tab, "Serial monitor")
|
||||
tabs.addTab(self.flash_tab, "Flash firmware")
|
||||
self.sd_tab = SdTab(self.serial_tab)
|
||||
tabs.addTab(self.sd_tab, "SD card")
|
||||
self.setCentralWidget(tabs)
|
||||
settings_menu = self.menuBar().addMenu("Settings")
|
||||
settings_menu.addAction("Service system address...", self._edit_service_base)
|
||||
|
||||
def _edit_service_base(self) -> None:
|
||||
st = self.serial_tab
|
||||
text, ok = QInputDialog.getText(
|
||||
self, "Service system address",
|
||||
"Address of the KOTA service system (the box MAC is added at the end):",
|
||||
text=st._service_base())
|
||||
if ok and text.strip().startswith(("https://", "http://")):
|
||||
st._settings.setValue("service/base", text.strip())
|
||||
elif ok:
|
||||
QMessageBox.warning(self, "Service system address", "The address must start with https://")
|
||||
|
||||
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||
self.serial_tab.shutdown()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""SD card tab: format a card for the sensorbox and write / check ENV.TXT."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, QSettings, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..sd.disks import Disk, list_cards
|
||||
from ..sd.envfile import EnvConfig, load_template
|
||||
from ..sd.provision import Outcome, check_card, format_and_write, write_env
|
||||
|
||||
# The shared settings file the factory script uses, next to the tool folders.
|
||||
DEFAULT_TEMPLATE = Path(__file__).resolve().parents[4] / "002-SD_card_format" / "ENV.txt"
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
done = Signal(object)
|
||||
listed = Signal(object)
|
||||
|
||||
|
||||
def _secret_field() -> tuple[QLineEdit, QCheckBox]:
|
||||
edit = QLineEdit()
|
||||
edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
show = QCheckBox("show")
|
||||
show.toggled.connect(lambda on: edit.setEchoMode(
|
||||
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
|
||||
return edit, show
|
||||
|
||||
|
||||
class SdTab(QWidget):
|
||||
def __init__(self, serial_tab, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.serial_tab = serial_tab
|
||||
self._settings = QSettings("KOTA", "TechnicianTool")
|
||||
self._cards: list[Disk] = []
|
||||
self._busy = False
|
||||
self._listing = False
|
||||
|
||||
# --- 1. settings
|
||||
sbox = QGroupBox("1. Settings written to the card (ENV.TXT)")
|
||||
self.template_label = QLabel(wordWrap=True)
|
||||
load_btn = QPushButton("Load settings from ENV file...")
|
||||
load_btn.clicked.connect(self._choose_template)
|
||||
self.url = QLineEdit()
|
||||
self.token, show_token = _secret_field()
|
||||
form = QFormLayout()
|
||||
form.addRow("INFLUX_URL", self.url)
|
||||
trow = QHBoxLayout()
|
||||
trow.addWidget(self.token, 1)
|
||||
trow.addWidget(show_token)
|
||||
form.addRow("INFLUX_TOKEN", trow)
|
||||
self.wifi_box = QGroupBox("Local Wi-Fi instead of Matter (optional)")
|
||||
self.wifi_box.setCheckable(True)
|
||||
self.wifi_box.setChecked(False)
|
||||
self.ssid = QLineEdit()
|
||||
self.password, show_pw = _secret_field()
|
||||
wform = QFormLayout(self.wifi_box)
|
||||
wform.addRow("SSID", self.ssid)
|
||||
prow = QHBoxLayout()
|
||||
prow.addWidget(self.password, 1)
|
||||
prow.addWidget(show_pw)
|
||||
wform.addRow("PASSWORD", prow)
|
||||
self.wifi_note = QLabel(wordWrap=True)
|
||||
self.wifi_note.setStyleSheet("color:#555")
|
||||
wform.addRow(self.wifi_note)
|
||||
self.issues = QLabel(wordWrap=True)
|
||||
sl = QVBoxLayout(sbox)
|
||||
sl.addWidget(self.template_label)
|
||||
sl.addWidget(load_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
sl.addLayout(form)
|
||||
sl.addWidget(self.wifi_box)
|
||||
sl.addWidget(self.issues)
|
||||
|
||||
# --- 2. card
|
||||
cbox = QGroupBox("2. SD card (inside the sensorbox case)")
|
||||
self.card_list = QListWidget()
|
||||
self.card_list.setMinimumHeight(80)
|
||||
self.others = QLabel(wordWrap=True)
|
||||
self.others.setStyleSheet("color:#666")
|
||||
refresh = QPushButton("Refresh cards")
|
||||
refresh.clicked.connect(self.refresh_cards)
|
||||
cl = QVBoxLayout(cbox)
|
||||
cl.addWidget(QLabel("Insert the card into the laptop's reader. Only removable cards are listed."))
|
||||
cl.addWidget(self.card_list)
|
||||
cl.addWidget(self.others)
|
||||
cl.addWidget(refresh, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
# --- 3. actions
|
||||
abox = QGroupBox("3. Action")
|
||||
self.format_btn = QPushButton("Format card and write ENV.TXT")
|
||||
self.format_btn.setMinimumHeight(34)
|
||||
self.format_btn.setToolTip("Erases the card: FAT32 (MBR), label KOTA, then writes and verifies ENV.TXT")
|
||||
self.write_btn = QPushButton("Write ENV.TXT only (keep card contents)")
|
||||
self.write_btn.setToolTip("Keeps buffered measurements (BUFFER.TXT) on the card")
|
||||
self.check_btn = QPushButton("Check card")
|
||||
self.check_btn.setToolTip("Show what the box would read from this card")
|
||||
self.format_btn.clicked.connect(self._format)
|
||||
self.write_btn.clicked.connect(self._write_only)
|
||||
self.check_btn.clicked.connect(self._check)
|
||||
self.result = QLabel(wordWrap=True)
|
||||
self.result.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.result.setMargin(8)
|
||||
self.log = QPlainTextEdit(readOnly=True)
|
||||
self.log.setMaximumBlockCount(500)
|
||||
al = QVBoxLayout(abox)
|
||||
for w in (self.format_btn, self.write_btn, self.check_btn, self.result, self.log):
|
||||
al.addWidget(w)
|
||||
|
||||
left = QVBoxLayout()
|
||||
left.addWidget(sbox)
|
||||
left.addStretch(1)
|
||||
right = QVBoxLayout()
|
||||
right.addWidget(cbox)
|
||||
right.addWidget(abox, 1)
|
||||
lay = QHBoxLayout(self)
|
||||
lay.addLayout(left, 1)
|
||||
lay.addLayout(right, 1)
|
||||
|
||||
self._bridge = _Bridge()
|
||||
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):
|
||||
w.textChanged.connect(self._update)
|
||||
self.wifi_box.toggled.connect(self._update)
|
||||
self.card_list.currentRowChanged.connect(self._update)
|
||||
|
||||
path = Path(self._settings.value("sd/template", str(DEFAULT_TEMPLATE)))
|
||||
self._load_template(path, quiet=True)
|
||||
self._show_result("", "")
|
||||
self._update()
|
||||
|
||||
# ---------------------------------------------------------- settings
|
||||
def _choose_template(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(self, "ENV file with the InfluxDB settings",
|
||||
str(DEFAULT_TEMPLATE.parent), "Text (*.txt *.env);;All files (*)")
|
||||
if path:
|
||||
self._load_template(Path(path))
|
||||
|
||||
def _load_template(self, path: Path, quiet: bool = False) -> None:
|
||||
try:
|
||||
env = load_template(path)
|
||||
except OSError as exc:
|
||||
self.template_label.setText("No settings file loaded - load the ENV file the firmware team gave you.")
|
||||
if not quiet:
|
||||
QMessageBox.warning(self, "Cannot read file", str(exc))
|
||||
return
|
||||
self._settings.setValue("sd/template", str(path))
|
||||
self.template_label.setText(f"Loaded from <code>{path}</code>")
|
||||
self.url.setText(env.influx_url)
|
||||
self.token.setText(env.influx_token)
|
||||
# Wi-Fi is decided per box by the technician, never copied silently.
|
||||
self._update()
|
||||
|
||||
def _env(self) -> EnvConfig:
|
||||
wifi = self.wifi_box.isChecked()
|
||||
return EnvConfig(self.url.text(), self.token.text(),
|
||||
self.ssid.text() if wifi else "", self.password.text() if wifi else "")
|
||||
|
||||
def _update(self) -> None:
|
||||
env = self._env()
|
||||
issues = env.problems()
|
||||
colour = {"error": "#b03a2e", "warning": "#c77c02", "info": "#2e6da4"}
|
||||
self.issues.setText("<br>".join(f"<span style='color:{colour[i.level]}'>{i.level.upper()}: {i.text}</span>"
|
||||
for i in issues) or "<span style='color:#2e9e44'>Settings OK.</span>")
|
||||
fw = self.serial_tab.engine.firmware
|
||||
support = fw.supports_sd_wifi
|
||||
self.wifi_note.setText(
|
||||
"Only for boxes that should NOT use Matter. The box then joins this Wi-Fi directly. "
|
||||
+ {True: "The connected box's firmware supports this.",
|
||||
False: "<b>The connected box runs the factory v1.0 firmware, which ignores these settings "
|
||||
"(it only uses Matter).</b> Newer firmware (after an OTA update) uses them.",
|
||||
None: "Supported by newer firmware only; the factory v1.0 firmware ignores it."}[support])
|
||||
errors = any(i.level == "error" for i in issues)
|
||||
card = self._selected_card()
|
||||
mounted = bool(card and card.volumes and card.volumes[0].mount)
|
||||
self.format_btn.setEnabled(not self._busy and not errors and card is not None)
|
||||
self.write_btn.setEnabled(not self._busy and not errors and mounted)
|
||||
self.check_btn.setEnabled(not self._busy and mounted)
|
||||
|
||||
# -------------------------------------------------------------- cards
|
||||
def showEvent(self, event): # noqa: N802
|
||||
self.refresh_cards()
|
||||
super().showEvent(event)
|
||||
|
||||
def refresh_cards(self) -> None:
|
||||
"""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
|
||||
self.card_list.clear()
|
||||
for d in self._cards:
|
||||
self.card_list.addItem(QListWidgetItem(d.label))
|
||||
if self._cards:
|
||||
self.card_list.setCurrentRow(0)
|
||||
empty = [d for d in others if not d.has_media]
|
||||
big = [d for d in others if d.has_media]
|
||||
notes = []
|
||||
if empty:
|
||||
notes.append("Card reader found but no card inserted: " + ", ".join(d.name for d in empty) + ".")
|
||||
if big:
|
||||
notes.append("Not offered (too large to be an SD card): " + ", ".join(d.label for d in big) + ".")
|
||||
if not self._cards and not empty:
|
||||
notes.append("No SD card or card reader found.")
|
||||
self.others.setText(" ".join(notes))
|
||||
self._update()
|
||||
|
||||
def _selected_card(self) -> Disk | None:
|
||||
i = self.card_list.currentRow()
|
||||
return self._cards[i] if 0 <= i < len(self._cards) else None
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
def _format(self) -> None:
|
||||
card = self._selected_card()
|
||||
if card is None:
|
||||
return
|
||||
box = QMessageBox(QMessageBox.Icon.Warning, "Format SD card",
|
||||
f"ALL DATA ON THIS CARD WILL BE ERASED:\n\n{card.label}\n\n"
|
||||
"Buffered measurements on the card are lost too. Continue?",
|
||||
QMessageBox.StandardButton.Cancel, self)
|
||||
erase = box.addButton("Erase and format", QMessageBox.ButtonRole.DestructiveRole)
|
||||
box.exec()
|
||||
if box.clickedButton() is not erase:
|
||||
return
|
||||
env = self._env()
|
||||
self._run(lambda: format_and_write(card, env), f"Formatting {card.name}...")
|
||||
|
||||
def _write_only(self) -> None:
|
||||
card = self._selected_card()
|
||||
if card and card.volumes:
|
||||
env, mount = self._env(), card.volumes[0].mount
|
||||
self._run(lambda: write_env(mount, env), f"Writing ENV.TXT to {mount}...")
|
||||
|
||||
def _check(self) -> None:
|
||||
card = self._selected_card()
|
||||
if not (card and card.volumes):
|
||||
return
|
||||
vol = card.volumes[0]
|
||||
rep = check_card(vol.mount, vol.filesystem)
|
||||
lines = [f"Card {vol.mount} ({vol.filesystem or '?'})"]
|
||||
if rep.env:
|
||||
lines += [f" {k}={v}" for k, v in rep.env.masked().items()]
|
||||
lines += [f" {i.level.upper()}: {i.text}" for i in rep.env.issues]
|
||||
lines += [f" PROBLEM: {p}" for p in rep.problems]
|
||||
if rep.buffered_lines is not None:
|
||||
lines.append(f" {rep.buffered_lines} measurement lines buffered on the card, waiting for upload.")
|
||||
self.log.appendPlainText("\n".join(lines))
|
||||
ok = not rep.problems and rep.env is not None and rep.env.ok
|
||||
self._show_result("Card is OK for the sensorbox." if ok else "Card has problems - see below. "
|
||||
"Use 'Write ENV.TXT only' or 'Format card'.", "ok" if ok else "error")
|
||||
|
||||
def _run(self, job, what: str) -> None:
|
||||
self._busy = True
|
||||
self._update()
|
||||
self._show_result(what, "info")
|
||||
|
||||
def worker():
|
||||
try:
|
||||
out = job()
|
||||
except Exception as exc: # report, never leave the tab busy
|
||||
out = Outcome(False, f"Unexpected error: {exc}")
|
||||
self._bridge.done.emit(out)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _on_done(self, out: Outcome) -> None:
|
||||
self._busy = False
|
||||
for line in out.log:
|
||||
self.log.appendPlainText(line)
|
||||
self.log.appendPlainText(out.message)
|
||||
if out.ok:
|
||||
self._show_result(f"<b>{out.message}</b><br>Insert the next card and press Refresh cards to "
|
||||
"provision another one.", "ok")
|
||||
else:
|
||||
self._show_result(f"<b>{out.message}</b>", "error")
|
||||
self.refresh_cards()
|
||||
|
||||
def _show_result(self, html: str, kind: str) -> None:
|
||||
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"), "error": ("#d9534f", "#f9e0df")}
|
||||
self.result.setVisible(bool(html))
|
||||
if html:
|
||||
border, bg = colours[kind]
|
||||
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
||||
self.result.setText(html)
|
||||
|
||||
|
||||
+3
-2
@@ -1,16 +1,17 @@
|
||||
[project]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
||||
requires-python = ">=3.11,<3.14"
|
||||
dependencies = [
|
||||
"pyserial>=3.5",
|
||||
"PySide6-Essentials>=6.7",
|
||||
"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]
|
||||
dev = ["pytest>=8"]
|
||||
dev = ["pytest>=8", "pyfatfs>=1.0", "setuptools<81"] # pyfatfs (independent FAT32 check) needs pkg_resources
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
# Keep the virtual environment out of the (Google Drive) tool folder.
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.4"
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.6"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
rem Keep the virtual environment out of the (Google Drive) tool folder.
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.4"
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.6"
|
||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||
|
||||
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"
|
||||
+113
-1
@@ -52,7 +52,7 @@ def test_modified_image_is_refused(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patch,msg", [
|
||||
((0, 0x00), "not a merged"),
|
||||
((0, 0x00), "not an ESP32 firmware"),
|
||||
((12, 0x05), "not ESP32-S3"),
|
||||
])
|
||||
def test_wrong_images_are_rejected(tmp_path, patch, msg):
|
||||
@@ -160,3 +160,115 @@ def test_runner_reports_connect_failure_with_hint(image, tmp_path):
|
||||
res, *_ = _run(plan, cmd)
|
||||
assert not res.ok and res.failed_step == 0
|
||||
assert "BOOT" in res.hint
|
||||
|
||||
|
||||
# ------------------------------------------------- developer builds (v0.5)
|
||||
|
||||
from kota_tech.flash.image import FirmwareLibrary, app_image_length # noqa: E402
|
||||
|
||||
|
||||
def _app_only_file(image, tmp_path, name="kota-sensorboard.bin"):
|
||||
p = tmp_path / name
|
||||
p.write_bytes(image.app_bytes()) # what a developer build/ folder contains
|
||||
return p
|
||||
|
||||
|
||||
def test_integrity_check_on_factory_image(image):
|
||||
d = image.data
|
||||
assert app_image_length(d, 0) > 0 # bootloader
|
||||
assert app_image_length(d, 0x40000) == len(d) - 0x40000 # app ends exactly at file end
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage,msg", [
|
||||
(lambda b: b[:len(b) // 2], "truncated"),
|
||||
(lambda b: b[:0x40000 + 5000] + bytes([b[0x40000 + 5000] ^ 1]) + b[0x40000 + 5001:], "corrupted"),
|
||||
])
|
||||
def test_corrupted_or_truncated_images_refused(tmp_path, damage, msg):
|
||||
p = tmp_path / "broken.bin"
|
||||
p.write_bytes(damage(FW.read_bytes()))
|
||||
with pytest.raises(ImageError, match=msg):
|
||||
inspect_image(p, origin="developer")
|
||||
|
||||
|
||||
def test_import_app_only_developer_build(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
src = _app_only_file(image, tmp_path)
|
||||
img = lib.import_file(src, reference=image)
|
||||
assert img.is_developer and img.kind == "app"
|
||||
assert img.partition("ota_0").offset == 0x40000 # layout from the factory image
|
||||
assert img.app_bytes() == image.app_bytes()
|
||||
listed, problems = lib.images(image)
|
||||
assert [i.sha256 for i in listed] == [img.sha256] and problems == []
|
||||
assert lib.import_file(src, reference=image).sha256 == img.sha256 # re-import: no duplicate
|
||||
assert len(lib.images(image)[0]) == 1
|
||||
|
||||
|
||||
def test_app_only_build_cannot_be_flashed_full(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
plan = build_plan(img, Mode.APP_ONLY, "COM5", tmp_path / "w")
|
||||
assert [s.kind for s in plan.steps] == ["read_pt", "write"]
|
||||
with pytest.raises(ValueError, match="app only"):
|
||||
build_plan(img, Mode.FULL, "COM5", tmp_path / "w")
|
||||
|
||||
|
||||
def test_import_merged_developer_build(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
src = tmp_path / "dev_full.bin"
|
||||
src.write_bytes(image.data)
|
||||
img = lib.import_file(src, reference=image)
|
||||
assert img.kind == "merged" and img.is_developer
|
||||
assert [s.kind for s in build_plan(img, Mode.FULL, "COM5", tmp_path / "w").steps] == ["write"]
|
||||
|
||||
|
||||
def test_library_detects_file_changed_after_import(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
stored = tmp_path / "lib" / img.path.name
|
||||
data = bytearray(stored.read_bytes())
|
||||
data[-40] ^= 1
|
||||
stored.write_bytes(data)
|
||||
listed, problems = lib.images(image)
|
||||
assert listed == [] and problems
|
||||
|
||||
|
||||
def test_library_remove(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
lib.remove(img.sha256)
|
||||
assert lib.images(image) == ([], []) and not img.path.exists()
|
||||
|
||||
|
||||
def test_non_firmware_file_refused(tmp_path, image):
|
||||
p = tmp_path / "notes.bin"
|
||||
p.write_bytes(b"hello firmware team")
|
||||
with pytest.raises(ImageError, match="not an ESP32 firmware"):
|
||||
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
|
||||
@@ -0,0 +1,172 @@
|
||||
"""SD provisioning: ENV.TXT (firmware-exact parsing), disk discovery parsers,
|
||||
the elevated Windows format script, and write/check on a stand-in card."""
|
||||
|
||||
import plistlib
|
||||
|
||||
import pytest
|
||||
|
||||
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.provision import check_card, win_format_command, write_env
|
||||
|
||||
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
|
||||
|
||||
|
||||
def cfg(**kw):
|
||||
base = dict(influx_url=URL, influx_token=TOKEN)
|
||||
base.update(kw)
|
||||
return EnvConfig(**base)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ ENV.TXT
|
||||
|
||||
def test_render_matches_the_factory_file_format():
|
||||
raw = cfg().render()
|
||||
assert raw == f"INFLUX_TOKEN={TOKEN}\nINFLUX_URL={URL}\n".encode()
|
||||
assert not raw.startswith(b"\xef\xbb\xbf") and b"\r" not in raw
|
||||
assert parse_env(raw).ok
|
||||
|
||||
|
||||
def test_wifi_only_written_when_both_set():
|
||||
raw = cfg(ssid="Site WiFi", password="correct horse").render().decode()
|
||||
assert "SSID=Site WiFi\n" in raw and "PASSWORD=correct horse\n" in raw
|
||||
assert any("both SSID and PASSWORD" in i.text for i in cfg(ssid="x").problems())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kw,fragment", [
|
||||
(dict(influx_url="influx.kota-app.com"), "https://"),
|
||||
(dict(influx_url="https://influx.kota-app.com/"), "write URL"),
|
||||
(dict(influx_token=""), "INFLUX_TOKEN is empty"),
|
||||
(dict(influx_token="abc def" * 5), "spaces"),
|
||||
(dict(influx_token=TOKEN + " "), "space"),
|
||||
(dict(ssid="s" * 33, password="12345678"), "32 bytes"),
|
||||
(dict(ssid="net", password="short"), "8-63"),
|
||||
])
|
||||
def test_invalid_settings_are_errors(kw, fragment):
|
||||
errors = [i.text for i in cfg(**kw).problems() if i.level == "error"]
|
||||
assert any(fragment in e for e in errors), errors
|
||||
|
||||
|
||||
def test_parser_mirrors_firmware():
|
||||
raw = b"\xef\xbb\xbfINFLUX_TOKEN=abc\r\nINFLUX_URL = x\nINFLUX_URL=u1\nINFLUX_URL=u2\nHTTP_SERVER=true\n"
|
||||
chk = parse_env(raw)
|
||||
texts = " ".join(i.text for i in chk.issues)
|
||||
assert "BOM" in texts # first key unreadable
|
||||
assert "spaces around the key 'INFLUX_URL'" in texts
|
||||
assert chk.values["INFLUX_URL"] == "u1" # first match wins
|
||||
assert "appears twice" in texts and "setup mode" in texts
|
||||
assert "INFLUX_TOKEN is missing" in texts # hidden behind the BOM
|
||||
assert not chk.ok
|
||||
|
||||
|
||||
def test_masking():
|
||||
chk = parse_env(cfg(ssid="net", password="secretpassword").render())
|
||||
m = chk.masked()
|
||||
assert TOKEN not in m["INFLUX_TOKEN"] and m["INFLUX_TOKEN"].startswith("TTTT")
|
||||
assert "secret" not in m["PASSWORD"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- disk lists
|
||||
|
||||
WIN_JSON = """[{"Number":0,"FriendlyName":"ST500DM002-1BC142","Size":500107862016,"BusType":"SATA",
|
||||
"Volumes":[{"DriveLetter":"D","FileSystem":"NTFS","Label":"WINRE"}]},
|
||||
{"Number":4,"FriendlyName":"Mass Storage Device","Size":0,"BusType":"USB","Volumes":[]},
|
||||
{"Number":5,"FriendlyName":"SDXC Card","Size":31914983424,"BusType":"SD",
|
||||
"Volumes":{"DriveLetter":"E","FileSystem":"FAT32","Label":"KOTA"}}]"""
|
||||
|
||||
|
||||
def test_parse_windows_keeps_only_card_buses():
|
||||
disks = parse_windows(WIN_JSON)
|
||||
assert [d.id for d in disks] == ["4", "5"] # SATA data disk never listed
|
||||
reader, card = disks
|
||||
assert not reader.has_media and reader.size_text == "no card"
|
||||
assert card.volumes[0].mount == "E:\\" and card.volumes[0].filesystem == "FAT32"
|
||||
|
||||
|
||||
def test_parse_windows_single_object_quirk():
|
||||
one = '{"Number":5,"FriendlyName":"SD","Size":8000000000,"BusType":"USB","Volumes":null}'
|
||||
assert [d.id for d in parse_windows(one)] == ["5"]
|
||||
|
||||
|
||||
def test_parse_macos_excludes_system_disk():
|
||||
listing = plistlib.dumps({"AllDisksAndPartitions": [
|
||||
{"DeviceIdentifier": "disk0", "Size": 500_000_000_000, "Partitions": []},
|
||||
{"DeviceIdentifier": "disk3", "Size": 994_000_000_000, "Partitions": [], "APFSVolumes": [{"MountPoint": "/"}]},
|
||||
{"DeviceIdentifier": "disk4", "Size": 15_931_539_456,
|
||||
"Partitions": [{"DeviceIdentifier": "disk4s1", "MountPoint": "/Volumes/KOTA", "Content": "DOS_FAT_32",
|
||||
"VolumeName": "KOTA"}]},
|
||||
]})
|
||||
infos = {"disk0": plistlib.dumps({"Internal": True, "BusProtocol": "PCI-Express"}),
|
||||
"disk3": plistlib.dumps({"Internal": True}),
|
||||
"disk4": plistlib.dumps({"Internal": True, "RemovableMedia": True, "BusProtocol": "Secure Digital",
|
||||
"MediaName": "SD Card Reader"})}
|
||||
disks = parse_macos(listing, infos)
|
||||
assert [d.id for d in disks] == ["/dev/disk4"] and disks[0].bus == "SD"
|
||||
assert disks[0].volumes[0].mount == "/Volumes/KOTA"
|
||||
|
||||
|
||||
# -------------------------------------------------------- format script
|
||||
|
||||
def test_elevated_helper_command(tmp_path):
|
||||
disk = Disk("5", "SDXC Card", 63_864_569_856, "SD")
|
||||
prog, params, cwd = win_format_command(disk, tmp_path / "r.json")
|
||||
assert params.startswith("-m kota_tech.sd.win_format --disk 5 --size 63864569856 --label KOTA")
|
||||
assert (Path(cwd) / "kota_tech").is_dir() # helper is importable from there
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows formatter")
|
||||
@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():
|
||||
assert MAX_CARD_BYTES == 256 * 1024**3
|
||||
|
||||
|
||||
# ----------------------------------------------- write / check on a card
|
||||
|
||||
def test_write_env_replaces_variants_and_keeps_buffered_data(tmp_path):
|
||||
card = tmp_path / "card"
|
||||
card.mkdir()
|
||||
(card / "env.txt").write_bytes(b"INFLUX_URL=old\n")
|
||||
(card / "BUFFER.TXT").write_bytes(b"line1\nline2\n\n")
|
||||
out = write_env(str(card), cfg())
|
||||
assert out.ok, out.message
|
||||
names = sorted(p.name for p in card.iterdir())
|
||||
assert names == ["BUFFER.TXT", "ENV.TXT"]
|
||||
rep = check_card(str(card), "FAT32")
|
||||
assert rep.env.ok and rep.problems == [] and rep.buffered_lines == 2
|
||||
|
||||
|
||||
def test_write_env_refuses_invalid_settings(tmp_path):
|
||||
assert not write_env(str(tmp_path), cfg(influx_token="")).ok
|
||||
assert not (tmp_path / "ENV.TXT").exists()
|
||||
|
||||
|
||||
def test_check_card_reports_missing_env_and_wrong_fs(tmp_path):
|
||||
rep = check_card(str(tmp_path), "exFAT")
|
||||
texts = " ".join(rep.problems)
|
||||
assert "needs FAT32" in texts and "No ENV.TXT" in texts
|
||||
|
||||
|
||||
def test_elevated_result_folder_is_not_owner_only(tmp_path, monkeypatch):
|
||||
# Regression: tempfile.mkdtemp() made the elevated script's result unreadable.
|
||||
import kota_tech.sd.provision as prov
|
||||
monkeypatch.setattr(prov, "WORK_ROOT", tmp_path / "tmp")
|
||||
work = prov._workdir()
|
||||
assert work.parent == tmp_path / "tmp" and work.is_dir()
|
||||
(work / "result.json").write_text('{"ok": true}', encoding="utf-8")
|
||||
assert (work / "result.json").read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Service-system hand-off: status mapping, note, and the #kota-report fragment
|
||||
(decoded exactly as the v1.1.0 service page does: base64url + UTF-8 JSON)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
from kota_tech.core.logline import parse_line
|
||||
from kota_tech.diag.engine import DiagnosticsEngine
|
||||
from kota_tech.service.report import (
|
||||
CONNECTIVITY, HARDWARE, OK, decode_fragment, normalize_mac, report_url, service_note, service_status,
|
||||
)
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def bench():
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(FIX / "live_boot_A4CB8FC946F8_fw1.0.txt"):
|
||||
e.feed(ln)
|
||||
return e
|
||||
|
||||
|
||||
def test_engine_reads_mac_from_boot_log():
|
||||
assert bench().mac == "A4:CB:8F:C9:46:F8"
|
||||
|
||||
|
||||
def test_bench_box_is_a_connectivity_problem():
|
||||
e = bench()
|
||||
assert service_status(e.report()) == CONNECTIVITY
|
||||
note = service_note(e.report())
|
||||
assert note.startswith("[KOTA Technician Tool")
|
||||
assert "not commissioned in Matter" in note and "-> No Internet" in note
|
||||
assert "Firmware: v1.0" in note
|
||||
|
||||
|
||||
def test_hardware_and_healthy_statuses():
|
||||
e = DiagnosticsEngine()
|
||||
for ln in ["ESP-ROM:esp32s3-20210327", "I (1299) app_init: Project name: kota-sensorboard",
|
||||
"E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT"]:
|
||||
e.feed(parse_line(ln))
|
||||
assert service_status(e.report()) == HARDWARE
|
||||
ok = DiagnosticsEngine()
|
||||
for ln in ["ESP-ROM:esp32s3-20210327", "I (1299) app_init: Project name: kota-sensorboard",
|
||||
"I (31000) INFLUX: HTTP request performed, status code: 204"]:
|
||||
ok.feed(parse_line(ln))
|
||||
assert service_status(ok.report()) == OK
|
||||
|
||||
|
||||
def test_url_shape_and_payload_roundtrip():
|
||||
e = bench()
|
||||
url = report_url(e.mac, e.report(), "https://service.kota-app.com")
|
||||
assert url.startswith("https://service.kota-app.com/A4CB8FC946F8#kota-report=")
|
||||
assert "?" not in url # nothing in the query: the fragment is never sent to a server
|
||||
data = decode_fragment(url)
|
||||
assert data["v"] == 1 and data["mac"] == "A4CB8FC946F8" and data["status"] == CONNECTIVITY
|
||||
|
||||
|
||||
def test_fragment_decodes_like_the_service_page():
|
||||
# Mirror of index.html applyToolPrefill: base64url -> pad -> bytes -> UTF-8 -> JSON
|
||||
e = bench()
|
||||
e.note_technician("Zażółć gęślą jaźń -> Purple") # non-ASCII survives
|
||||
url = report_url("a4:cb:8f:c9:46:f8", e.report())
|
||||
frag = url.split("#kota-report=")[1]
|
||||
assert set(frag) <= set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") # page regex
|
||||
padded = frag.replace("-", "+").replace("_", "/")
|
||||
padded += "==="[(len(padded) + 3) % 4:]
|
||||
data = json.loads(base64.b64decode(padded).decode("utf-8"))
|
||||
assert "Zażółć gęślą jaźń" in data["note"]
|
||||
|
||||
|
||||
def test_note_is_limited_and_no_secrets():
|
||||
e = bench()
|
||||
for i in range(400):
|
||||
e.note_technician(f"check {i} " + "x" * 50)
|
||||
note = service_note(e.report(), limit=4000)
|
||||
assert len(note) <= 4000
|
||||
assert "INFLUX_TOKEN" not in note
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "A4CB8F", "zz:cb:8f:c9:46:f8"])
|
||||
def test_invalid_mac_rejected(bad):
|
||||
with pytest.raises(ValueError):
|
||||
report_url(bad, bench().report())
|
||||
|
||||
|
||||
def test_normalize_mac():
|
||||
assert normalize_mac("a4:cb:8f:c9:46:f8") == "A4CB8FC946F8"
|
||||
@@ -6,6 +6,15 @@ resolution-markers = [
|
||||
"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]]
|
||||
name = "argcomplete"
|
||||
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" }
|
||||
|
||||
[[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]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -227,17 +250,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "esptool" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyside6-essentials" },
|
||||
{ name = "segno" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyfatfs" },
|
||||
{ name = "pytest" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -245,10 +271,15 @@ requires-dist = [
|
||||
{ name = "esptool", specifier = "~=4.8" },
|
||||
{ name = "pyserial", specifier = ">=3.5" },
|
||||
{ name = "pyside6-essentials", specifier = ">=6.7" },
|
||||
{ name = "segno", specifier = ">=1.6" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
dev = [
|
||||
{ name = "pyfatfs", specifier = ">=1.0" },
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "setuptools", specifier = "<81" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pygments"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "shiboken6"
|
||||
version = "6.11.2"
|
||||
|
||||
Reference in new issue
Block a user