Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22bd853ecf | ||
|
|
f5b32e1554 | ||
|
|
72adfe1cec |
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).
|
||||||
@@ -7,4 +7,6 @@ dependencies:
|
|||||||
- python=3.12
|
- python=3.12
|
||||||
- pyserial>=3.5
|
- pyserial>=3.5
|
||||||
- pyside6>=6.7
|
- pyside6>=6.7
|
||||||
|
- esptool>=4.8,<5
|
||||||
|
- segno>=1.6
|
||||||
- pytest>=8
|
- pytest>=8
|
||||||
Binary file not shown.
@@ -1,3 +1,3 @@
|
|||||||
"""KOTA sensorbox technician tool."""
|
"""KOTA sensorbox technician tool."""
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.6.0"
|
||||||
@@ -25,6 +25,7 @@ from ..core.logline import LineKind, LogLine
|
|||||||
from .firmware import FirmwareInfo
|
from .firmware import FirmwareInfo
|
||||||
|
|
||||||
RULES_PATH = Path(__file__).with_name("rules.toml")
|
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):
|
class Severity(IntEnum):
|
||||||
@@ -278,6 +279,8 @@ class DiagnosticsEngine:
|
|||||||
self._tool_reset_at: float | None = None
|
self._tool_reset_at: float | None = None
|
||||||
self._panic_tail = 0 # RAW lines after a panic line to keep
|
self._panic_tail = 0 # RAW lines after a panic line to keep
|
||||||
self.technician: list[str] = []
|
self.technician: list[str] = []
|
||||||
|
self.mac: str | None = None # from "ID: Device MAC address: ..." at boot
|
||||||
|
self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def boot(self) -> BootRecord:
|
def boot(self) -> BootRecord:
|
||||||
@@ -317,6 +320,10 @@ class DiagnosticsEngine:
|
|||||||
self._crash_finding().evidence.append(text)
|
self._crash_finding().evidence.append(text)
|
||||||
|
|
||||||
self.firmware.observe(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
|
matched = False
|
||||||
for rule in self.rules:
|
for rule in self.rules:
|
||||||
m = rule.pattern.search(text)
|
m = rule.pattern.search(text)
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ KNOWN: dict[str, KnownFirmware] = {
|
|||||||
sd_wifi=False,
|
sd_wifi=False,
|
||||||
setup_portal=False,
|
setup_portal=False,
|
||||||
),
|
),
|
||||||
|
"c94ba0e9b": KnownFirmware(
|
||||||
|
name="v1.0 source build (Sep 26 2026)",
|
||||||
|
source="developer build, built by Krzysztof Kallas",
|
||||||
|
sd_wifi=True,
|
||||||
|
setup_portal=True,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
|
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class Step:
|
|||||||
skip_if_finding: str | None = None
|
skip_if_finding: str | None = None
|
||||||
skip_unless_feature: str | None = None
|
skip_unless_feature: str | None = None
|
||||||
skip_to: str | None = None
|
skip_to: str | None = None
|
||||||
|
show_qr: bool = False # show the Matter pairing QR next to the step text
|
||||||
|
|
||||||
def targets(self) -> list[str]:
|
def targets(self) -> list[str]:
|
||||||
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
||||||
@@ -101,7 +102,7 @@ def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
|
|||||||
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
|
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
|
||||||
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
||||||
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
|
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
|
||||||
skip_to=s.get("skip_to"),
|
skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
|
||||||
)
|
)
|
||||||
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
||||||
for e in g.get("end", [])}
|
for e in g.get("end", [])}
|
||||||
|
|||||||
+41
-21
@@ -64,7 +64,7 @@ title = "Firmware is not running normally"
|
|||||||
text = "The heartbeat LED should blink once per second whenever the firmware runs. A steady heartbeat LED means the firmware has frozen."
|
text = "The heartbeat LED should blink once per second whenever the firmware runs. A steady heartbeat LED means the firmware has frozen."
|
||||||
actions = [
|
actions = [
|
||||||
"Switch the box off and on with the power switch.",
|
"Switch the box off and on with the power switch.",
|
||||||
"If it happens again: save the session log and re-flash the official firmware (Flash tab, coming in v0.4).",
|
"If it happens again: save the session log and re-flash the official firmware (Flash firmware tab, App only).",
|
||||||
]
|
]
|
||||||
|
|
||||||
# =============================================================== network
|
# =============================================================== network
|
||||||
@@ -113,7 +113,8 @@ next = "pair_start"
|
|||||||
[[guide.step]]
|
[[guide.step]]
|
||||||
id = "pair_start"
|
id = "pair_start"
|
||||||
type = "wait"
|
type = "wait"
|
||||||
text = "In the Matter app choose 'Add device' and scan the QR code on the sticker (or type the 11-digit code printed under it). Keep the phone within 1 m of the box."
|
text = "In the Matter app choose 'Add device' and scan the QR code shown here (the same as the sticker), or type the 11-digit manual code. Keep the phone within 1 m of the box."
|
||||||
|
show_qr = true
|
||||||
until = 'MAIN: Commissioning session started'
|
until = 'MAIN: Commissioning session started'
|
||||||
timeout_s = 300
|
timeout_s = 300
|
||||||
on_ok = "pair_wifi"
|
on_ok = "pair_wifi"
|
||||||
@@ -379,7 +380,7 @@ severity = "critical"
|
|||||||
title = "SD card is unreadable or has wrong settings"
|
title = "SD card is unreadable or has wrong settings"
|
||||||
text = "Even after re-seating, the card or its ENV.TXT cannot be used."
|
text = "Even after re-seating, the card or its ENV.TXT cannot be used."
|
||||||
actions = [
|
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).",
|
"The Diagnosis tab shows which step failed (card, file system or ENV.TXT keys).",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -597,7 +598,7 @@ title = "Box does not start"
|
|||||||
text = "Neither LED is lit: no power or the firmware does not start."
|
text = "Neither LED is lit: no power or the firmware does not start."
|
||||||
actions = [
|
actions = [
|
||||||
"Check the power supply and cable.",
|
"Check the power supply and cable.",
|
||||||
"If the USB port shows up in this tool, re-flash the official firmware (Flash tab, coming in v0.4).",
|
"If the USB port shows up in this tool, re-flash the official firmware (Flash firmware tab, App only).",
|
||||||
]
|
]
|
||||||
|
|
||||||
# =================================================================== clock
|
# =================================================================== clock
|
||||||
@@ -651,29 +652,48 @@ default = "clap"
|
|||||||
[[guide.step]]
|
[[guide.step]]
|
||||||
id = "switch"
|
id = "switch"
|
||||||
type = "question"
|
type = "question"
|
||||||
text = "The firmware reads the microphone as switched off. Does this box have a microphone ON/OFF switch or jumper (inside the case)?"
|
text = "The box reads its microphone switch as OFF. The switch is on the back of the case, just above the power switch (no need to open the box). Should this box measure noise?"
|
||||||
options = [
|
options = [
|
||||||
{ label = "Yes - I set it to ON", next = "switch_verify" },
|
{ label = "Yes - I flipped the microphone switch", next = "switch_verify" },
|
||||||
{ label = "No such switch / not sure", next = "end:mic_enable_line" },
|
{ label = "No - it is meant to be off", next = "end:mic_off_ok" },
|
||||||
{ label = "It is meant to be off", next = "end:mic_off_ok" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[guide.step]]
|
[[guide.step]]
|
||||||
id = "switch_verify"
|
id = "switch_verify"
|
||||||
type = "wait"
|
type = "wait"
|
||||||
text = "The tool restarts the box and waits for a microphone reading (up to 60 s)..."
|
# The firmware re-reads the switch before every reading (~10 s): no restart needed.
|
||||||
tool_action = "reset"
|
text = "Waiting for the next microphone reading (up to 30 s)..."
|
||||||
until = 'MICROPHONE: Read \d+ bytes'
|
until = 'MICROPHONE: Read \d+ bytes'
|
||||||
fail_on = 'Microphone is in shutdown state'
|
fail_on = 'Microphone is in shutdown state'
|
||||||
timeout_s = 60
|
timeout_s = 30
|
||||||
on_ok = "end:mic_on"
|
on_ok = "end:mic_on"
|
||||||
on_fail = "end:mic_enable_line"
|
on_fail = "flip_back"
|
||||||
on_timeout = "end:mic_enable_line"
|
on_timeout = "flip_back"
|
||||||
|
|
||||||
|
[[guide.step]]
|
||||||
|
id = "flip_back"
|
||||||
|
type = "question"
|
||||||
|
text = "The box still reads the switch as OFF. Flip the microphone switch to the other position (it may have been ON already)."
|
||||||
|
options = [
|
||||||
|
{ label = "Done - I flipped it to the other position", next = "switch_verify2" },
|
||||||
|
{ label = "Leave it off", next = "end:mic_off_ok" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.step]]
|
||||||
|
id = "switch_verify2"
|
||||||
|
type = "wait"
|
||||||
|
text = "Waiting for the next microphone reading (up to 30 s)..."
|
||||||
|
until = 'MICROPHONE: Read \d+ bytes'
|
||||||
|
fail_on = 'Microphone is in shutdown state'
|
||||||
|
timeout_s = 30
|
||||||
|
on_ok = "end:mic_on"
|
||||||
|
on_fail = "end:mic_switch_faulty"
|
||||||
|
on_timeout = "end:mic_switch_faulty"
|
||||||
|
|
||||||
[[guide.step]]
|
[[guide.step]]
|
||||||
id = "clap"
|
id = "clap"
|
||||||
type = "wait"
|
type = "wait"
|
||||||
text = "Clap your hands or speak loudly right next to the box for about 20 seconds..."
|
text = "Check the microphone switch (back of the case, above the power switch) is ON. Then clap your hands or speak loudly right next to the box for about 20 seconds..."
|
||||||
until = 'dB peak: (?!-inf)'
|
until = 'dB peak: (?!-inf)'
|
||||||
timeout_s = 45
|
timeout_s = 45
|
||||||
on_ok = "end:mic_ok"
|
on_ok = "end:mic_ok"
|
||||||
@@ -683,8 +703,8 @@ on_timeout = "end:mic_faulty"
|
|||||||
id = "mic_on"
|
id = "mic_on"
|
||||||
severity = "ok"
|
severity = "ok"
|
||||||
title = "Microphone is on and recording"
|
title = "Microphone is on and recording"
|
||||||
text = "The firmware now reads audio samples."
|
text = "The firmware reads audio samples again."
|
||||||
actions = []
|
actions = ["Leave the microphone switch in this position."]
|
||||||
|
|
||||||
[[guide.end]]
|
[[guide.end]]
|
||||||
id = "mic_off_ok"
|
id = "mic_off_ok"
|
||||||
@@ -694,11 +714,11 @@ text = "No noise data is recorded for this box; everything else works."
|
|||||||
actions = []
|
actions = []
|
||||||
|
|
||||||
[[guide.end]]
|
[[guide.end]]
|
||||||
id = "mic_enable_line"
|
id = "mic_switch_faulty"
|
||||||
severity = "warning"
|
severity = "warning"
|
||||||
title = "Microphone enable line stays 'off'"
|
title = "Microphone switch has no effect"
|
||||||
text = "The firmware keeps reading the microphone as switched off (GPIO3)."
|
text = "The box reads the microphone as OFF in both switch positions (enable line GPIO3)."
|
||||||
actions = ["Check the microphone board and its cable/connector (inside the case); send the box MAC to the firmware team to confirm the hardware variant."]
|
actions = ["Check the switch and its cable to the main board (inside the case); replace the switch if needed."]
|
||||||
|
|
||||||
[[guide.end]]
|
[[guide.end]]
|
||||||
id = "mic_ok"
|
id = "mic_ok"
|
||||||
@@ -711,5 +731,5 @@ actions = []
|
|||||||
id = "mic_faulty"
|
id = "mic_faulty"
|
||||||
severity = "error"
|
severity = "error"
|
||||||
title = "Microphone records nothing"
|
title = "Microphone records nothing"
|
||||||
text = "Even loud noise next to the box gives no signal."
|
text = "Even loud noise next to the box gives no signal, although the switch is on."
|
||||||
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
|
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
|
||||||
@@ -558,8 +558,8 @@ id = "mic.disabled"
|
|||||||
pattern = 'MICROPHONE: Microphone is in shutdown state'
|
pattern = 'MICROPHONE: Microphone is in shutdown state'
|
||||||
severity = "info"
|
severity = "info"
|
||||||
title = "Microphone is switched off - no noise measurements"
|
title = "Microphone is switched off - no noise measurements"
|
||||||
meaning = "The firmware reads the microphone enable line (GPIO3) as 'off', which it treats as disabled on purpose. Noise levels are not recorded."
|
meaning = "The microphone switch on the back of the case (just above the power switch) is OFF, so noise levels are not recorded."
|
||||||
actions = ["If this box should measure noise: check the microphone enable switch/jumper and its wiring (inside the case)."]
|
actions = ["If this box should measure noise: flip the microphone switch (back of the case, above the power switch). No restart needed - it is picked up within about 10 s."]
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
id = "gather.empty_reading"
|
id = "gather.empty_reading"
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1,303 @@
|
|||||||
|
"""Firmware images: validation, integrity and layout.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Partition:
|
||||||
|
label: str
|
||||||
|
type: int
|
||||||
|
subtype: int
|
||||||
|
offset: int
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AppInfo:
|
||||||
|
project: str
|
||||||
|
version: str
|
||||||
|
built: str # "Jun 29 2026 11:03:32" as in the app descriptor
|
||||||
|
idf: str
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
|
||||||
|
def app_bytes(self) -> bytes:
|
||||||
|
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 = []
|
||||||
|
for i in range(0, len(raw) - 31, 32):
|
||||||
|
entry = raw[i:i + 32]
|
||||||
|
magic = entry[:2]
|
||||||
|
if magic == b"\xaa\x50":
|
||||||
|
_, ptype, sub, off, size = struct.unpack_from("<HBBII", entry)
|
||||||
|
label = entry[12:28].split(b"\0")[0].decode("ascii", "replace")
|
||||||
|
parts.append(Partition(label, ptype, sub, off, size))
|
||||||
|
elif magic in (b"\xeb\xeb", b"\xff\xff"): # MD5 entry / end
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ImageError(f"corrupt partition table entry at 0x{PT_OFFSET + i:x}")
|
||||||
|
return tuple(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def app_image_length(data: bytes, offset: int = 0) -> int:
|
||||||
|
"""Validate an ESP app image at `offset`; return its exact length.
|
||||||
|
|
||||||
|
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")
|
||||||
|
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 _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 []
|
||||||
|
out = []
|
||||||
|
for e in tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", []):
|
||||||
|
path = manifest.parent / e["file"]
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
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 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
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Flash plans: which esptool steps run, in which order.
|
||||||
|
|
||||||
|
App-only (default, repairs): check the box has the same partition table,
|
||||||
|
then write the app to ota_0 and erase otadata so ota_0 boots. NVS is kept:
|
||||||
|
Matter pairing, OTA registration and Wi-Fi survive.
|
||||||
|
|
||||||
|
Full (new or wiped boards): write the whole factory image at 0x0, the same
|
||||||
|
as the factory flash_all.bat. This erases NVS: pairing and OTA registration
|
||||||
|
are lost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .image import PT_OFFSET, PT_SIZE, FirmwareImage
|
||||||
|
|
||||||
|
BAUD = "460800"
|
||||||
|
|
||||||
|
|
||||||
|
class Mode(str, enum.Enum):
|
||||||
|
APP_ONLY = "app_only"
|
||||||
|
FULL = "full"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Step:
|
||||||
|
kind: str # "backup" | "read_pt" | "write"
|
||||||
|
title: str
|
||||||
|
args: list[str] # esptool arguments (no interpreter prefix)
|
||||||
|
est_s: int # rough duration for the UI
|
||||||
|
output: Path | None = None # file produced by the step (backup / table)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Plan:
|
||||||
|
mode: Mode
|
||||||
|
image: FirmwareImage
|
||||||
|
port: str
|
||||||
|
steps: list[Step] = field(default_factory=list)
|
||||||
|
workdir: Path | None = None
|
||||||
|
|
||||||
|
def summary(self) -> list[str]:
|
||||||
|
return [f"{i}. {s.title} (~{_dur(s.est_s)})" for i, s in enumerate(self.steps, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _dur(s: int) -> str:
|
||||||
|
return f"{s // 60} min" if s >= 90 else f"{s} s"
|
||||||
|
|
||||||
|
|
||||||
|
def _common(port: str, after: str) -> list[str]:
|
||||||
|
return ["--chip", "esp32s3", "--port", port, "--baud", BAUD,
|
||||||
|
"--before", "default_reset", "--after", after]
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(image: FirmwareImage, mode: Mode, port: str, workdir: Path,
|
||||||
|
backup_to: Path | None = None) -> Plan:
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
plan = Plan(mode, image, port, workdir=workdir)
|
||||||
|
if backup_to is not None:
|
||||||
|
size = {"4MB": "0x400000", "8MB": "0x800000", "16MB": "0x1000000"}.get(image.flash_size, "0x800000")
|
||||||
|
plan.steps.append(Step(
|
||||||
|
"backup", f"Back up the box's complete flash to {backup_to.name}",
|
||||||
|
_common(port, "no_reset") + ["read_flash", "0", size, str(backup_to)],
|
||||||
|
est_s=200 if size == "0x800000" else 100, output=backup_to))
|
||||||
|
|
||||||
|
write_opts = ["write_flash", "--flash_mode", "keep", "--flash_freq", "keep", "--flash_size", "keep"]
|
||||||
|
if mode == Mode.APP_ONLY:
|
||||||
|
pt_file = workdir / "box_partition_table.bin"
|
||||||
|
plan.steps.append(Step(
|
||||||
|
"read_pt", "Check the box has the same partition layout",
|
||||||
|
_common(port, "no_reset") + ["read_flash", hex(PT_OFFSET), hex(PT_SIZE), str(pt_file)],
|
||||||
|
est_s=5, output=pt_file))
|
||||||
|
app_file = workdir / "app_ota0.bin"
|
||||||
|
ota_file = workdir / "otadata_blank.bin"
|
||||||
|
app_file.write_bytes(image.app_bytes())
|
||||||
|
ota_file.write_bytes(image.blank_otadata())
|
||||||
|
otadata, ota0 = image.partition("otadata"), image.partition("ota_0")
|
||||||
|
plan.steps.append(Step(
|
||||||
|
"write", "Write the firmware app (keeps pairing, OTA registration, settings)",
|
||||||
|
_common(port, "hard_reset") + write_opts + [
|
||||||
|
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 image (erases pairing and OTA registration)",
|
||||||
|
_common(port, "hard_reset") + write_opts + ["0x0", str(image.path)],
|
||||||
|
est_s=25))
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def check_partition_table(box_table: bytes, image: FirmwareImage) -> str | None:
|
||||||
|
"""None if App-only is safe, otherwise the reason it is not."""
|
||||||
|
if box_table.strip(b"\xff") == b"":
|
||||||
|
return "The box has no partition table (blank or erased chip). Use Full mode."
|
||||||
|
if box_table != image.partition_table:
|
||||||
|
return ("The box uses a different partition layout than this image. "
|
||||||
|
"App-only could leave it unbootable - use Full mode.")
|
||||||
|
return None
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Runs a flash plan: one esptool subprocess per step, in a background thread.
|
||||||
|
|
||||||
|
A subprocess (instead of calling esptool in-process) keeps esptool's
|
||||||
|
stdout/sys.exit handling away from the GUI and lets the tool read progress
|
||||||
|
line by line. Callbacks are invoked from the worker thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .plan import Plan, check_partition_table
|
||||||
|
|
||||||
|
_PROGRESS = re.compile(r"\((\d{1,3}) ?%\)")
|
||||||
|
|
||||||
|
# esptool output -> technician-facing hint
|
||||||
|
HINTS = [
|
||||||
|
(re.compile(r"could not open port|Access is denied|PermissionError|Resource busy", re.I),
|
||||||
|
"The USB port is in use. Close other programs that use the sensorbox port (serial monitors, Arduino IDE) and try again."),
|
||||||
|
(re.compile(r"Failed to connect to ESP32-S3|No serial data received|Wrong boot mode", re.I),
|
||||||
|
"The box did not enter flashing mode by itself. Open the case, hold the BOOT button, press and release RESET, "
|
||||||
|
"release BOOT, then press Start again."),
|
||||||
|
(re.compile(r"device reports readiness to read but returned no data|The port disappeared|device disconnected", re.I),
|
||||||
|
"The USB connection dropped. Use a short data-capable USB cable directly on the laptop (no hub) and try again."),
|
||||||
|
(re.compile(r"Invalid head of packet|timed out", re.I),
|
||||||
|
"Communication errors. Try another USB cable or port."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
hint: str = ""
|
||||||
|
failed_step: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FlashRunner:
|
||||||
|
def __init__(self, plan: Plan,
|
||||||
|
on_step: Callable[[int, str], None],
|
||||||
|
on_progress: Callable[[int], None],
|
||||||
|
on_output: Callable[[str], None],
|
||||||
|
on_done: Callable[[Result], None],
|
||||||
|
esptool_cmd: list[str] | None = None):
|
||||||
|
self.plan = plan
|
||||||
|
self._on_step, self._on_progress = on_step, on_progress
|
||||||
|
self._on_output, self._on_done = on_output, on_done
|
||||||
|
self._cmd = esptool_cmd or [sys.executable, "-m", "esptool"]
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._thread = threading.Thread(target=self._run, name="flash", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def join(self, timeout: float | None = None) -> None:
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
try:
|
||||||
|
self._on_done(self._execute())
|
||||||
|
except Exception as exc: # never leave the UI waiting
|
||||||
|
self._on_done(Result(False, f"Internal error: {exc}"))
|
||||||
|
|
||||||
|
def _execute(self) -> Result:
|
||||||
|
for i, step in enumerate(self.plan.steps):
|
||||||
|
self._on_step(i, step.title)
|
||||||
|
self._on_progress(0)
|
||||||
|
code, output = self._esptool(step.args)
|
||||||
|
if code != 0:
|
||||||
|
return Result(False, f"Step {i + 1} failed: {step.title}", _hint(output), i)
|
||||||
|
if step.kind == "read_pt":
|
||||||
|
problem = check_partition_table(step.output.read_bytes(), self.plan.image)
|
||||||
|
if problem:
|
||||||
|
return Result(False, problem, failed_step=i)
|
||||||
|
self._on_progress(100)
|
||||||
|
return Result(True, "Firmware written and verified by esptool.")
|
||||||
|
|
||||||
|
def _esptool(self, args: list[str]) -> tuple[int, str]:
|
||||||
|
cmd = self._cmd + args
|
||||||
|
self._on_output("$ esptool " + " ".join(args))
|
||||||
|
flags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
creationflags=flags)
|
||||||
|
collected: list[str] = []
|
||||||
|
buf = b""
|
||||||
|
assert proc.stdout is not None
|
||||||
|
while chunk := proc.stdout.read1(4096): # returns as soon as bytes arrive
|
||||||
|
buf += chunk
|
||||||
|
# esptool redraws progress with '\r'; treat it as a line break.
|
||||||
|
*lines, buf = re.split(rb"[\r\n]", buf)
|
||||||
|
for raw in lines:
|
||||||
|
self._line(raw, collected)
|
||||||
|
if buf:
|
||||||
|
self._line(buf, collected)
|
||||||
|
return proc.wait(), "\n".join(collected)
|
||||||
|
|
||||||
|
def _line(self, raw: bytes, collected: list[str]) -> None:
|
||||||
|
text = raw.decode("utf-8", "replace").strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
collected.append(text)
|
||||||
|
if m := _PROGRESS.search(text):
|
||||||
|
self._on_progress(min(int(m.group(1)), 100))
|
||||||
|
return # progress lines are not worth listing one by one
|
||||||
|
self._on_output(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _hint(output: str) -> str:
|
||||||
|
for pattern, hint in HINTS:
|
||||||
|
if pattern.search(output):
|
||||||
|
return hint
|
||||||
|
return "See the esptool output below. Save it and send it to the firmware team if the problem repeats."
|
||||||
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"))
|
||||||
@@ -40,7 +40,8 @@ HINTS = [
|
|||||||
"reboot name the cause.",
|
"reboot name the cause.",
|
||||||
"<b>Small LED next to the RGB LED</b> blinks once per second while the "
|
"<b>Small LED next to the RGB LED</b> blinks once per second while the "
|
||||||
"firmware runs. Not blinking = firmware hung or not running.",
|
"firmware runs. Not blinking = firmware hung or not running.",
|
||||||
"<b>Outside the case</b>: power switch and the Matter button. Holding the "
|
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
|
||||||
|
"(off = no noise measurements), and the Matter button. Holding the "
|
||||||
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
|
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
|
||||||
"network and must be commissioned again with the sticker QR code).",
|
"network and must be commissioned again with the sticker QR code).",
|
||||||
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
|
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QTextBrowser,
|
QTextBrowser,
|
||||||
@@ -99,6 +100,9 @@ class DiagnosticsPanel(QWidget):
|
|||||||
lay.addLayout(led_row)
|
lay.addLayout(led_row)
|
||||||
lay.addWidget(_heading("Firmware"))
|
lay.addWidget(_heading("Firmware"))
|
||||||
lay.addWidget(self.firmware)
|
lay.addWidget(self.firmware)
|
||||||
|
self.qr_btn = QPushButton("Matter pairing QR code...")
|
||||||
|
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
|
||||||
|
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||||||
lay.addWidget(_heading("Start-up progress (current boot)"))
|
lay.addWidget(_heading("Start-up progress (current boot)"))
|
||||||
lay.addWidget(self.milestones)
|
lay.addWidget(self.milestones)
|
||||||
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"""Flash firmware tab: factory image or imported developer builds; App-only by default."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup,
|
||||||
|
QCheckBox,
|
||||||
|
QFileDialog,
|
||||||
|
QInputDialog,
|
||||||
|
QComboBox,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.session import SESSIONS_DIR
|
||||||
|
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
|
||||||
|
|
||||||
|
BACKUP_DIR = SESSIONS_DIR.parent / "backups"
|
||||||
|
VERIFY_TIMEOUT_MS = 45_000
|
||||||
|
|
||||||
|
HELP = (
|
||||||
|
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
|
||||||
|
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
|
||||||
|
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Bridge(QObject):
|
||||||
|
step = Signal(int, str)
|
||||||
|
progress = Signal(int)
|
||||||
|
output = Signal(str)
|
||||||
|
done = Signal(object)
|
||||||
|
|
||||||
|
|
||||||
|
class FlashTab(QWidget):
|
||||||
|
def __init__(self, serial_tab, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.serial_tab = serial_tab
|
||||||
|
self._images: list[FirmwareImage] = []
|
||||||
|
self._ports: list[PortInfo] = [] # before any signal can call _update()
|
||||||
|
self._runner: FlashRunner | None = None
|
||||||
|
self._port: PortInfo | None = None
|
||||||
|
self._image: FirmwareImage | None = None
|
||||||
|
|
||||||
|
# 1. box
|
||||||
|
box = QGroupBox("1. Sensorbox")
|
||||||
|
self.port_combo = QComboBox(minimumWidth=360)
|
||||||
|
refresh = QPushButton("Refresh")
|
||||||
|
refresh.clicked.connect(self.refresh_ports)
|
||||||
|
self.box_fw = QLabel(wordWrap=True)
|
||||||
|
bl = QVBoxLayout(box)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.addWidget(self.port_combo)
|
||||||
|
row.addWidget(refresh)
|
||||||
|
row.addStretch(1)
|
||||||
|
bl.addLayout(row)
|
||||||
|
bl.addWidget(self.box_fw)
|
||||||
|
|
||||||
|
# 2. firmware
|
||||||
|
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")
|
||||||
|
self.rb_app = QRadioButton("App only (recommended for repairs)")
|
||||||
|
self.rb_full = QRadioButton("Full factory image (new or completely wiped boards)")
|
||||||
|
self.mode_group = QButtonGroup(self)
|
||||||
|
self.mode_group.addButton(self.rb_app)
|
||||||
|
self.mode_group.addButton(self.rb_full)
|
||||||
|
self.rb_app.setChecked(True)
|
||||||
|
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
|
||||||
|
"settings stored on the box.", wordWrap=True)
|
||||||
|
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
|
||||||
|
"registration</b> - the box must be paired again afterwards.", wordWrap=True)
|
||||||
|
for n in (app_note, full_note):
|
||||||
|
n.setContentsMargins(24, 0, 0, 6)
|
||||||
|
n.setStyleSheet("color:#555")
|
||||||
|
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
|
||||||
|
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
|
||||||
|
ml = QVBoxLayout(modebox)
|
||||||
|
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
|
||||||
|
ml.addWidget(w)
|
||||||
|
|
||||||
|
# 4. run
|
||||||
|
runbox = QGroupBox("4. Flash")
|
||||||
|
self.plan_label = QLabel(wordWrap=True)
|
||||||
|
self.start_btn = QPushButton("Start flashing")
|
||||||
|
self.start_btn.setMinimumHeight(36)
|
||||||
|
self.start_btn.clicked.connect(self._start)
|
||||||
|
self.step_label = QLabel()
|
||||||
|
self.bar = QProgressBar()
|
||||||
|
self.result = QLabel(wordWrap=True)
|
||||||
|
self.result.setMargin(8)
|
||||||
|
self.result.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.details_btn = QPushButton("Show esptool output")
|
||||||
|
self.details_btn.setCheckable(True)
|
||||||
|
self.output = QPlainTextEdit(readOnly=True)
|
||||||
|
self.output.setVisible(False)
|
||||||
|
self.details_btn.toggled.connect(self.output.setVisible)
|
||||||
|
rl = QVBoxLayout(runbox)
|
||||||
|
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
|
||||||
|
rl.addWidget(w)
|
||||||
|
help_lbl = QLabel(HELP, wordWrap=True)
|
||||||
|
help_lbl.setStyleSheet("color:#555")
|
||||||
|
rl.addWidget(help_lbl)
|
||||||
|
rl.addStretch(1) # keep the run column packed at the top
|
||||||
|
|
||||||
|
left = QVBoxLayout()
|
||||||
|
for w in (box, fwbox, modebox):
|
||||||
|
left.addWidget(w)
|
||||||
|
left.addStretch(1)
|
||||||
|
lay = QHBoxLayout(self)
|
||||||
|
lay.addLayout(left, 1)
|
||||||
|
lay.addWidget(runbox, 1)
|
||||||
|
|
||||||
|
self._bridge = _Bridge()
|
||||||
|
self._bridge.step.connect(self._on_step)
|
||||||
|
self._bridge.progress.connect(self.bar.setValue)
|
||||||
|
self._bridge.output.connect(self.output.appendPlainText)
|
||||||
|
self._bridge.done.connect(self._on_done)
|
||||||
|
self._verify_timer = QTimer(self, interval=500)
|
||||||
|
self._verify_timer.timeout.connect(self._check_verify)
|
||||||
|
|
||||||
|
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
|
||||||
|
sig.connect(self._update)
|
||||||
|
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
|
||||||
|
self.image_combo.currentIndexChanged.connect(self._update)
|
||||||
|
self.port_combo.currentIndexChanged.connect(self._update)
|
||||||
|
|
||||||
|
self._load_images()
|
||||||
|
self.refresh_ports()
|
||||||
|
self._show_result("", None)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ setup
|
||||||
|
def _load_images(self, select_sha: str | None = None) -> None:
|
||||||
|
self._problems = []
|
||||||
|
try:
|
||||||
|
bundled = bundled_images()
|
||||||
|
except ImageError as exc:
|
||||||
|
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.label())
|
||||||
|
if not self._images:
|
||||||
|
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()
|
||||||
|
self._ports = list_serial_ports()
|
||||||
|
self.port_combo.clear()
|
||||||
|
for p in self._ports:
|
||||||
|
self.port_combo.addItem(p.label, p.device)
|
||||||
|
if not self._ports:
|
||||||
|
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
|
||||||
|
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
|
||||||
|
self.refresh_ports()
|
||||||
|
super().showEvent(event)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ state
|
||||||
|
def _selected_image(self) -> FirmwareImage | None:
|
||||||
|
i = self.image_combo.currentIndex()
|
||||||
|
return self._images[i] if 0 <= i < len(self._images) else None
|
||||||
|
|
||||||
|
def _selected_port(self) -> PortInfo | None:
|
||||||
|
dev = self.port_combo.currentData()
|
||||||
|
return next((p for p in self._ports if p.device == dev), None)
|
||||||
|
|
||||||
|
def _update(self) -> None:
|
||||||
|
img = self._selected_image()
|
||||||
|
info = ""
|
||||||
|
if img:
|
||||||
|
a = img.app
|
||||||
|
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
|
||||||
|
and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
|
||||||
|
self.start_btn.setEnabled(ok)
|
||||||
|
if img and self._selected_port():
|
||||||
|
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
|
||||||
|
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
|
||||||
|
if mode == Mode.APP_ONLY:
|
||||||
|
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
|
||||||
|
else:
|
||||||
|
steps += ["Write the complete factory image (~25 s)"]
|
||||||
|
steps += ["Restart the box and verify the new firmware boots"]
|
||||||
|
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
|
||||||
|
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()
|
||||||
|
if not img or not port:
|
||||||
|
return
|
||||||
|
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
|
||||||
|
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
|
||||||
|
else "the COMPLETE factory image (pairing and OTA registration are erased)")
|
||||||
|
if QMessageBox.question(self, "Flash firmware",
|
||||||
|
f"Write {what}\n\nImage: {img.name}\nBox: {port.label}\n\n"
|
||||||
|
"Keep the box powered and the USB cable connected until it finishes.",
|
||||||
|
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||||
|
) != QMessageBox.StandardButton.Ok:
|
||||||
|
return
|
||||||
|
backup_to = None
|
||||||
|
if self.backup.isChecked():
|
||||||
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
|
||||||
|
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
|
||||||
|
|
||||||
|
self._port, self._image = port, img
|
||||||
|
self.serial_tab.release_port() # esptool needs the port exclusively
|
||||||
|
self.output.clear()
|
||||||
|
self.output.appendPlainText("\n".join(plan.summary()))
|
||||||
|
self._show_result("", None)
|
||||||
|
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
|
||||||
|
self._bridge.output.emit, self._bridge.done.emit)
|
||||||
|
self._set_busy(True)
|
||||||
|
self._runner.start()
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _on_step(self, i: int, title: str) -> None:
|
||||||
|
n = len(self._runner.plan.steps) if self._runner else 0
|
||||||
|
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
|
||||||
|
|
||||||
|
def _on_done(self, res: Result) -> None:
|
||||||
|
self._runner = None
|
||||||
|
if not res.ok:
|
||||||
|
self._set_busy(False)
|
||||||
|
self.step_label.setText("Stopped.")
|
||||||
|
hint = f"<p>{res.hint}</p>" if res.hint else ""
|
||||||
|
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
|
||||||
|
self.details_btn.setChecked(True)
|
||||||
|
return
|
||||||
|
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
|
||||||
|
self.bar.setValue(0)
|
||||||
|
self._show_result("Firmware written. Waiting for the box to start...", "info")
|
||||||
|
self.serial_tab.connect_port(self._port, capture=True)
|
||||||
|
self._verify_ms = 0
|
||||||
|
self._verify_timer.start()
|
||||||
|
|
||||||
|
def _check_verify(self) -> None:
|
||||||
|
self._verify_ms += self._verify_timer.interval()
|
||||||
|
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
|
||||||
|
fw = self.serial_tab.engine.firmware
|
||||||
|
want = self._image.app.elf_sha[:9]
|
||||||
|
done = fw.elf_sha is not None and fw.elf_sha.startswith(want)
|
||||||
|
if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
|
||||||
|
self._verify_timer.stop()
|
||||||
|
self._set_busy(False)
|
||||||
|
self.bar.setValue(100)
|
||||||
|
if done:
|
||||||
|
self.step_label.setText("Done.")
|
||||||
|
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
|
||||||
|
"Check the Diagnosis panel in the Serial monitor.", "ok")
|
||||||
|
else:
|
||||||
|
seen = fw.elf_sha[:9] if fw.elf_sha else "no boot seen"
|
||||||
|
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
|
||||||
|
"Open the Serial monitor and press Reset & capture.", "warning")
|
||||||
|
|
||||||
|
def _show_result(self, html: str, kind: str | None) -> None:
|
||||||
|
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
|
||||||
|
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
|
||||||
|
self.result.setVisible(bool(html))
|
||||||
|
if html:
|
||||||
|
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.engine import LED, DiagnosticsEngine
|
||||||
from ..diag.guides import GuideRunner, recommended
|
from ..diag.guides import GuideRunner, recommended
|
||||||
|
from ..matter.payload import SetupPayload
|
||||||
|
from .matter_qr import MatterQrWidget
|
||||||
|
|
||||||
END_STYLE = { # end severity -> (border, background)
|
END_STYLE = { # end severity -> (border, background)
|
||||||
"ok": ("#2e9e44", "#e3f4e7"),
|
"ok": ("#2e9e44", "#e3f4e7"),
|
||||||
@@ -52,9 +54,11 @@ def _esc(s: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class GuidePanel(QWidget):
|
class GuidePanel(QWidget):
|
||||||
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None):
|
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None,
|
||||||
|
matter_payload=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.runner, self.engine = runner, engine
|
self.runner, self.engine = runner, engine
|
||||||
|
self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
|
||||||
self._shown_key: tuple | None = None
|
self._shown_key: tuple | None = None
|
||||||
|
|
||||||
# --- picker (no guide running)
|
# --- picker (no guide running)
|
||||||
@@ -93,7 +97,8 @@ class GuidePanel(QWidget):
|
|||||||
self.end_box = QLabel(wordWrap=True)
|
self.end_box = QLabel(wordWrap=True)
|
||||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||||
self.end_box.setMargin(10)
|
self.end_box.setMargin(10)
|
||||||
for w in (self.guide_title, self.step_text):
|
self.qr = MatterQrWidget(SetupPayload(), 220)
|
||||||
|
for w in (self.guide_title, self.step_text, self.qr):
|
||||||
cl.addWidget(w)
|
cl.addWidget(w)
|
||||||
cl.addLayout(self.options)
|
cl.addLayout(self.options)
|
||||||
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
||||||
@@ -192,6 +197,10 @@ class GuidePanel(QWidget):
|
|||||||
is_end = v.end is not None
|
is_end = v.end is not None
|
||||||
self.step_text.setVisible(not is_end)
|
self.step_text.setVisible(not is_end)
|
||||||
self.step_text.setText(_esc(v.text))
|
self.step_text.setText(_esc(v.text))
|
||||||
|
show_qr = bool(v.step and v.step.show_qr)
|
||||||
|
if show_qr:
|
||||||
|
self.qr.set_payload(self._matter_payload())
|
||||||
|
self.qr.setVisible(show_qr)
|
||||||
for opt_key, label in v.options:
|
for opt_key, label in v.options:
|
||||||
b = QPushButton(label)
|
b = QPushButton(label)
|
||||||
b.setMinimumHeight(34)
|
b.setMinimumHeight(34)
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import 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.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
QPlainTextEdit,
|
QPlainTextEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
@@ -28,9 +30,14 @@ from ..diag.engine import DiagnosticsEngine
|
|||||||
from ..diag.guides import GuideRunner
|
from ..diag.guides import GuideRunner
|
||||||
from ..serialio.manager import SerialManager, State
|
from ..serialio.manager import SerialManager, State
|
||||||
from ..serialio.ports import PortInfo, list_serial_ports
|
from ..serialio.ports import PortInfo, list_serial_ports
|
||||||
|
from ..matter.payload import SetupPayload, payload_from_log
|
||||||
|
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
||||||
from .cheatsheet import cheatsheet_html
|
from .cheatsheet import cheatsheet_html
|
||||||
from .diag_panel import DiagnosticsPanel
|
from .diag_panel import DiagnosticsPanel
|
||||||
|
from .flash_tab import FlashTab
|
||||||
|
from .matter_qr import MatterQrDialog
|
||||||
from .guide_panel import GuidePanel
|
from .guide_panel import GuidePanel
|
||||||
|
from .sd_tab import SdTab
|
||||||
|
|
||||||
MAX_LINES = 20000
|
MAX_LINES = 20000
|
||||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||||
@@ -83,6 +90,14 @@ class SerialTab(QWidget):
|
|||||||
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
|
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
|
||||||
self.copy_btn = QPushButton("Copy report")
|
self.copy_btn = QPushButton("Copy report")
|
||||||
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
|
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()
|
row1 = QHBoxLayout()
|
||||||
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
||||||
@@ -91,7 +106,7 @@ class SerialTab(QWidget):
|
|||||||
row1.addStretch(1)
|
row1.addStretch(1)
|
||||||
row2 = QHBoxLayout()
|
row2 = QHBoxLayout()
|
||||||
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
|
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.addWidget(w)
|
||||||
row2.addStretch(1)
|
row2.addStretch(1)
|
||||||
|
|
||||||
@@ -105,7 +120,7 @@ class SerialTab(QWidget):
|
|||||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||||
|
|
||||||
self.diag = DiagnosticsPanel()
|
self.diag = DiagnosticsPanel()
|
||||||
self.guide = GuidePanel(self._runner, self._engine)
|
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
|
||||||
self.side_tabs = QTabWidget()
|
self.side_tabs = QTabWidget()
|
||||||
self.side_tabs.addTab(self.diag, "Diagnosis")
|
self.side_tabs.addTab(self.diag, "Diagnosis")
|
||||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||||
@@ -133,6 +148,8 @@ class SerialTab(QWidget):
|
|||||||
self.folder_btn.clicked.connect(self._open_folder)
|
self.folder_btn.clicked.connect(self._open_folder)
|
||||||
self.analyze_btn.clicked.connect(self._analyze_file)
|
self.analyze_btn.clicked.connect(self._analyze_file)
|
||||||
self.copy_btn.clicked.connect(self._copy_report)
|
self.copy_btn.clicked.connect(self._copy_report)
|
||||||
|
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
|
||||||
|
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
|
||||||
|
|
||||||
# Report refresh is throttled; lines can arrive at >100/s.
|
# Report refresh is throttled; lines can arrive at >100/s.
|
||||||
self._diag_timer = QTimer(self, interval=400)
|
self._diag_timer = QTimer(self, interval=400)
|
||||||
@@ -180,17 +197,74 @@ class SerialTab(QWidget):
|
|||||||
port = self._selected_port()
|
port = self._selected_port()
|
||||||
if port is None:
|
if port is None:
|
||||||
return
|
return
|
||||||
|
self._connect(port)
|
||||||
|
|
||||||
|
def _connect(self, port: PortInfo) -> None:
|
||||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||||
self._engine.reset()
|
self._engine.reset()
|
||||||
self._runner.cancel()
|
self._runner.cancel()
|
||||||
self._diag_dirty = True
|
self._diag_dirty = True
|
||||||
self._session.start(port.mac or port.device)
|
self._session.start(port.mac or port.device)
|
||||||
|
self._active_port = port
|
||||||
self._manager.connect(port)
|
self._manager.connect(port)
|
||||||
|
|
||||||
|
def matter_payload(self) -> SetupPayload:
|
||||||
|
"""Pairing payload from this box's log (VID/PID/discriminator), defaults otherwise."""
|
||||||
|
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [])
|
||||||
|
|
||||||
|
# ---- service system -------------------------------------------------
|
||||||
|
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:
|
||||||
|
return self._engine
|
||||||
|
|
||||||
|
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."""
|
||||||
|
self.release_port()
|
||||||
|
idx = self.port_combo.findData(port.device)
|
||||||
|
if idx < 0:
|
||||||
|
self.port_combo.addItem(port.label, port.device)
|
||||||
|
self._ports.append(port)
|
||||||
|
idx = self.port_combo.count() - 1
|
||||||
|
self.port_combo.setCurrentIndex(idx)
|
||||||
|
self._reset_on_connect = capture
|
||||||
|
self._connect(port)
|
||||||
|
|
||||||
def _on_state(self, state: State, detail: str) -> None:
|
def _on_state(self, state: State, detail: str) -> None:
|
||||||
running = state != State.DISCONNECTED
|
running = state != State.DISCONNECTED
|
||||||
|
if not running and self._manager.is_running:
|
||||||
|
return # stale event from a session that was already replaced
|
||||||
|
if state == State.CONNECTED and getattr(self, "_reset_on_connect", False):
|
||||||
|
self._reset_on_connect = False
|
||||||
|
QTimer.singleShot(300, self._manager.reset_board)
|
||||||
if not running and self._session.path is not None:
|
if not running and self._session.path is not None:
|
||||||
self._session.write_report(self._engine.report().to_text())
|
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._session.stop()
|
||||||
self.connect_btn.setText("Disconnect" if running else "Connect")
|
self.connect_btn.setText("Disconnect" if running else "Connect")
|
||||||
self.reset_btn.setEnabled(state == State.CONNECTED)
|
self.reset_btn.setEnabled(state == State.CONNECTED)
|
||||||
@@ -315,9 +389,26 @@ class MainWindow(QMainWindow):
|
|||||||
guide = QTextBrowser(openExternalLinks=True)
|
guide = QTextBrowser(openExternalLinks=True)
|
||||||
guide.setHtml(cheatsheet_html())
|
guide.setHtml(cheatsheet_html())
|
||||||
self.serial_tab = SerialTab()
|
self.serial_tab = SerialTab()
|
||||||
|
self.flash_tab = FlashTab(self.serial_tab)
|
||||||
tabs.addTab(guide, "Quick start")
|
tabs.addTab(guide, "Quick start")
|
||||||
tabs.addTab(self.serial_tab, "Serial monitor")
|
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)
|
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)
|
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||||
self.serial_tab.shutdown()
|
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)
|
||||||
|
|
||||||
|
|
||||||
+4
-2
@@ -1,15 +1,17 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kota-technician-tool"
|
name = "kota-technician-tool"
|
||||||
version = "0.3.0"
|
version = "0.6.0"
|
||||||
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
||||||
requires-python = ">=3.11,<3.14"
|
requires-python = ">=3.11,<3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
"PySide6-Essentials>=6.7",
|
"PySide6-Essentials>=6.7",
|
||||||
|
"esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands
|
||||||
|
"segno>=1.6", # QR code for Matter pairing (pure Python)
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8"]
|
dev = ["pytest>=8", "pyfatfs>=1.0", "setuptools<81"] # pyfatfs (independent FAT32 check) needs pkg_resources
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = false
|
package = false
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
cd "$(dirname "$0")" || exit 1
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
# Keep the virtual environment out of the (Google Drive) tool folder.
|
# Keep the virtual environment out of the (Google Drive) tool folder.
|
||||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.3"
|
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.6"
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
if ! command -v uv >/dev/null 2>&1; then
|
if ! command -v uv >/dev/null 2>&1; then
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ setlocal
|
|||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
rem Keep the virtual environment out of the (Google Drive) tool folder.
|
rem Keep the virtual environment out of the (Google Drive) tool folder.
|
||||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.3"
|
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.6"
|
||||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||||
|
|
||||||
where uv >nul 2>nul
|
where uv >nul 2>nul
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""FAT32/MBR layout: field-level checks against the FAT spec on an in-memory
|
||||||
|
disk, plus an independent FAT implementation (pyfatfs) mounting the result."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kota_tech.sd import fat32
|
||||||
|
|
||||||
|
MiB, GiB = 1024**2, 1024**3
|
||||||
|
|
||||||
|
|
||||||
|
def make_disk(size: int, label="KOTA") -> tuple[bytearray, fat32.Fat32Layout]:
|
||||||
|
lay = fat32.plan(size)
|
||||||
|
disk = bytearray(b"\xAA" * size) # old garbage everywhere
|
||||||
|
zeros, writes = fat32.build(lay, label, 0x12345678, 0xCAFEBABE, size)
|
||||||
|
for off, n in zeros:
|
||||||
|
disk[off:off + n] = bytes(n)
|
||||||
|
for off, data in writes:
|
||||||
|
disk[off:off + len(data)] = data
|
||||||
|
return disk, lay
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def small():
|
||||||
|
return make_disk(96 * MiB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mbr_has_one_fat32_lba_partition(small):
|
||||||
|
disk, lay = small
|
||||||
|
assert disk[510:512] == b"\x55\xAA"
|
||||||
|
status, ptype, lba, count = struct.unpack_from("<B3xB3xII", disk, 0x1BE)
|
||||||
|
assert (status, ptype, lba) == (0, 0x0C, fat32.PART_START // 512)
|
||||||
|
assert count == lay.part_sectors
|
||||||
|
assert disk[0x1CE:0x1FE] == bytes(48) # other entries empty
|
||||||
|
assert struct.unpack_from("<I", disk, 0x1B8)[0] == 0xCAFEBABE
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_sector_fields(small):
|
||||||
|
disk, lay = small
|
||||||
|
b = disk[fat32.PART_START:fat32.PART_START + 512]
|
||||||
|
bps, spc, rsvd, nfats, root_ent, tot16, media, fat16 = struct.unpack_from("<HBHBHHBH", b, 11)
|
||||||
|
assert (bps, spc, rsvd, nfats, root_ent, tot16, media, fat16) == (512, lay.sectors_per_cluster, 32, 2, 0, 0, 0xF8, 0)
|
||||||
|
hidden, tot32, fatsz, _, _, root_clus, fsinfo, bkboot = struct.unpack_from("<IIIHHIHH", b, 28)
|
||||||
|
assert hidden == fat32.PART_START // 512 and tot32 == lay.part_sectors and fatsz == lay.fat_sectors
|
||||||
|
assert (root_clus, fsinfo, bkboot) == (2, 1, 6)
|
||||||
|
assert b[66] == 0x29 and b[71:82] == b"KOTA " and b[82:90] == b"FAT32 " and b[510:] == b"\x55\xAA"
|
||||||
|
assert disk[fat32.PART_START + 6 * 512:fat32.PART_START + 7 * 512] == b # backup boot sector
|
||||||
|
|
||||||
|
|
||||||
|
def test_fat_is_large_enough_and_clusters_valid(small):
|
||||||
|
_, lay = small
|
||||||
|
assert fat32.MIN_CLUSTERS <= lay.clusters
|
||||||
|
assert lay.fat_sectors * 512 // 4 >= lay.clusters + 2
|
||||||
|
assert lay.data_start + lay.clusters * lay.sectors_per_cluster <= lay.part_sectors
|
||||||
|
|
||||||
|
|
||||||
|
def test_fsinfo_and_fat_entries(small):
|
||||||
|
disk, lay = small
|
||||||
|
info = disk[fat32.PART_START + 512:fat32.PART_START + 1024]
|
||||||
|
assert struct.unpack_from("<I", info, 0)[0] == 0x41615252
|
||||||
|
assert struct.unpack_from("<III", info, 484) == (0x61417272, lay.clusters - 1, 3)
|
||||||
|
assert struct.unpack_from("<I", info, 508)[0] == 0xAA550000
|
||||||
|
for fat in range(2):
|
||||||
|
off = fat32.PART_START + (32 + fat * lay.fat_sectors) * 512
|
||||||
|
assert struct.unpack_from("<III", disk, off) == (0x0FFFFFF8, 0x0FFFFFFF, 0x0FFFFFFF)
|
||||||
|
assert disk[off + 12:off + lay.fat_sectors * 512] == bytes(lay.fat_sectors * 512 - 12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_holds_only_the_label(small):
|
||||||
|
disk, lay = small
|
||||||
|
root = fat32.PART_START + lay.data_start * 512
|
||||||
|
assert disk[root:root + 11] == b"KOTA " and disk[root + 11] == 0x08
|
||||||
|
assert disk[root + 32:root + lay.sectors_per_cluster * 512] == bytes(lay.sectors_per_cluster * 512 - 32)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("size,spc", [(4 * GiB, 8), (15 * GiB, 16), (31_457_280_000, 32), (64 * GiB, 32)])
|
||||||
|
def test_real_card_sizes(size, spc):
|
||||||
|
lay = fat32.plan(size)
|
||||||
|
assert lay.sectors_per_cluster == spc
|
||||||
|
assert lay.part_sectors * 512 <= min(size - fat32.PART_START, 32 * GiB)
|
||||||
|
assert fat32.MIN_CLUSTERS <= lay.clusters < fat32.MAX_CLUSTERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_too_small_card_refused():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
fat32.plan(20 * MiB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_independent_fat_implementation_mounts_it(small, tmp_path):
|
||||||
|
pyfat = pytest.importorskip("pyfatfs.PyFat")
|
||||||
|
disk, lay = small
|
||||||
|
img = tmp_path / "part.img"
|
||||||
|
img.write_bytes(bytes(disk[fat32.PART_START:fat32.PART_START + lay.part_sectors * 512]))
|
||||||
|
fs = pyfat.PyFat()
|
||||||
|
fs.open(str(img))
|
||||||
|
assert fs.fat_type == pyfat.PyFat.FAT_TYPE_FAT32
|
||||||
|
assert fs.bpb_header["BPB_TotSec32"] == lay.part_sectors
|
||||||
|
fs.close()
|
||||||
|
from pyfatfs.PyFatFS import PyFatFS
|
||||||
|
with PyFatFS(str(img)) as ffs: # write + read back a file like ENV.TXT
|
||||||
|
ffs.writebytes("/ENV.TXT", b"INFLUX_URL=x\n")
|
||||||
|
assert ffs.readbytes("/ENV.TXT") == b"INFLUX_URL=x\n"
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"""Flashing: image validation on the real official image, plan building,
|
||||||
|
and the runner against a fake esptool (no hardware)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kota_tech.flash.image import ImageError, inspect_image, official_images
|
||||||
|
from kota_tech.flash.plan import Mode, build_plan, check_partition_table
|
||||||
|
from kota_tech.flash.runner import FlashRunner
|
||||||
|
|
||||||
|
FW = Path(__file__).resolve().parents[1] / "firmware" / "new_system.bin"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def image():
|
||||||
|
imgs = official_images()
|
||||||
|
assert len(imgs) == 1
|
||||||
|
return imgs[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ image
|
||||||
|
|
||||||
|
def test_official_image_is_the_deployed_v1_build(image):
|
||||||
|
assert image.app.project == "kota-sensorboard"
|
||||||
|
assert image.app.elf_sha.startswith("45fa9d5b9") # same as the live board
|
||||||
|
assert image.flash_size == "8MB"
|
||||||
|
assert image.partition("ota_0").offset == 0x40000
|
||||||
|
assert image.partition("otadata").offset == 0x31000
|
||||||
|
assert image.partition("nvs").offset == 0x10000
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_bytes_are_a_complete_app_image(image):
|
||||||
|
app = image.app_bytes()
|
||||||
|
assert app[0] == 0xE9
|
||||||
|
assert len(app) == len(image.data) - 0x40000 # ends where the file ends
|
||||||
|
assert len(image.blank_otadata()) == 0x2000 and set(image.blank_otadata()) == {0xFF}
|
||||||
|
|
||||||
|
|
||||||
|
def test_modified_image_is_refused(tmp_path):
|
||||||
|
data = bytearray(FW.read_bytes())
|
||||||
|
data[-1] ^= 0xFF
|
||||||
|
bad = tmp_path / "new_system.bin"
|
||||||
|
bad.write_bytes(data)
|
||||||
|
manifest = tmp_path / "firmware.toml"
|
||||||
|
manifest.write_text(f'[[image]]\nfile = "new_system.bin"\nsha256 = "{hashlib.sha256(FW.read_bytes()).hexdigest()}"\n')
|
||||||
|
with pytest.raises(ImageError, match="SHA-256"):
|
||||||
|
official_images(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("patch,msg", [
|
||||||
|
((0, 0x00), "not an ESP32 firmware"),
|
||||||
|
((12, 0x05), "not ESP32-S3"),
|
||||||
|
])
|
||||||
|
def test_wrong_images_are_rejected(tmp_path, patch, msg):
|
||||||
|
data = bytearray(FW.read_bytes())
|
||||||
|
data[patch[0]] = patch[1]
|
||||||
|
p = tmp_path / "x.bin"
|
||||||
|
p.write_bytes(data)
|
||||||
|
with pytest.raises(ImageError, match=msg):
|
||||||
|
inspect_image(p)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- plan
|
||||||
|
|
||||||
|
def test_app_only_plan_keeps_nvs(image, tmp_path):
|
||||||
|
plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path)
|
||||||
|
kinds = [s.kind for s in plan.steps]
|
||||||
|
assert kinds == ["read_pt", "write"]
|
||||||
|
write = plan.steps[-1].args
|
||||||
|
offsets = [a for a in write if a.startswith("0x")]
|
||||||
|
assert offsets == ["0x31000", "0x40000"] # otadata + ota_0 only
|
||||||
|
assert "0x10000" not in write and "0x0" not in write # NVS / bootloader untouched
|
||||||
|
assert write[write.index("--after") + 1] == "hard_reset"
|
||||||
|
assert (tmp_path / "app_ota0.bin").read_bytes() == image.app_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_plan_with_backup(image, tmp_path):
|
||||||
|
backup = tmp_path / "bk.bin"
|
||||||
|
plan = build_plan(image, Mode.FULL, "COM5", tmp_path, backup_to=backup)
|
||||||
|
assert [s.kind for s in plan.steps] == ["backup", "write"]
|
||||||
|
assert plan.steps[0].args[-3:] == ["0", "0x800000", str(backup)]
|
||||||
|
assert plan.steps[0].args[plan.steps[0].args.index("--after") + 1] == "no_reset"
|
||||||
|
assert plan.steps[1].args[-2:] == ["0x0", str(image.path)]
|
||||||
|
assert "Back up" in plan.summary()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_table_check(image):
|
||||||
|
assert check_partition_table(image.partition_table, image) is None
|
||||||
|
assert "blank" in check_partition_table(b"\xff" * 0xC00, image)
|
||||||
|
other = bytearray(image.partition_table)
|
||||||
|
other[40] ^= 1
|
||||||
|
assert "different partition layout" in check_partition_table(bytes(other), image)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- runner
|
||||||
|
|
||||||
|
FAKE = """
|
||||||
|
import sys, pathlib
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if "read_flash" in args:
|
||||||
|
out = pathlib.Path(args[-1])
|
||||||
|
src = pathlib.Path(r"{fw}").read_bytes()
|
||||||
|
off, size = int(args[-3], 0), int(args[-2], 0)
|
||||||
|
{pt_line}
|
||||||
|
print("Read ... (50 %)", end="\\r"); print("Read ... (100 %)")
|
||||||
|
sys.exit(0)
|
||||||
|
if "write_flash" in args:
|
||||||
|
for p in (10, 60, 100):
|
||||||
|
print(f"Writing at 0x00040000... ({{p}} %)", end="\\r", flush=True)
|
||||||
|
print(); print("Hash of data verified.")
|
||||||
|
sys.exit({write_code})
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _fake(tmp_path, write_code=0, pt="same", fail_text=""):
|
||||||
|
pt_line = {"same": "out.write_bytes(src[off:off+size])",
|
||||||
|
"blank": "out.write_bytes(b'\\xff' * size)"}[pt]
|
||||||
|
script = FAKE.format(fw=FW, pt_line=pt_line, write_code=write_code)
|
||||||
|
if fail_text:
|
||||||
|
script = script.replace('print("Hash of data verified.")', f'print("{fail_text}")')
|
||||||
|
f = tmp_path / "fake_esptool.py"
|
||||||
|
f.write_text(script)
|
||||||
|
return [sys.executable, str(f)]
|
||||||
|
|
||||||
|
|
||||||
|
def _run(plan, cmd):
|
||||||
|
steps, progress, output, done = [], [], [], threading.Event()
|
||||||
|
result = {}
|
||||||
|
r = FlashRunner(plan, lambda i, t: steps.append(i), progress.append, output.append,
|
||||||
|
lambda res: (result.setdefault("r", res), done.set()), esptool_cmd=cmd)
|
||||||
|
r.start()
|
||||||
|
assert done.wait(30)
|
||||||
|
return result["r"], steps, progress, output
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_app_only_success(image, tmp_path):
|
||||||
|
plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path)
|
||||||
|
res, steps, progress, output = _run(plan, _fake(tmp_path))
|
||||||
|
assert res.ok, res
|
||||||
|
assert steps == [0, 1]
|
||||||
|
assert 60 in progress and progress[-1] == 100
|
||||||
|
assert any("Hash of data verified" in o for o in output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_refuses_app_only_on_blank_chip(image, tmp_path):
|
||||||
|
plan = build_plan(image, Mode.APP_ONLY, "COM5", tmp_path)
|
||||||
|
res, steps, *_ = _run(plan, _fake(tmp_path, pt="blank"))
|
||||||
|
assert not res.ok and "Full mode" in res.message
|
||||||
|
assert steps == [0] # never reached the write step
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_reports_connect_failure_with_hint(image, tmp_path):
|
||||||
|
plan = build_plan(image, Mode.FULL, "COM5", tmp_path)
|
||||||
|
cmd = _fake(tmp_path, write_code=2,
|
||||||
|
fail_text="A fatal error occurred: Failed to connect to ESP32-S3: No serial data received.")
|
||||||
|
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"
|
||||||
+17
-4
@@ -182,17 +182,30 @@ def test_reset_action_failure_is_shown(rig):
|
|||||||
assert "connect" in runner.view().note.lower()
|
assert "connect" in runner.view().note.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_mic_disabled_switch_on_verified(rig):
|
def test_mic_switch_flip_verified_without_restart(rig):
|
||||||
engine, runner, clock, actions, board, _ = rig
|
engine, runner, clock, actions, board, _ = rig
|
||||||
_bench(engine)
|
_bench(engine)
|
||||||
runner.start("mic")
|
runner.start("mic")
|
||||||
assert runner.step.id == "switch"
|
assert runner.step.id == "switch"
|
||||||
runner.answer("0") # set it to ON
|
assert "above the power switch" in runner.view().text
|
||||||
assert actions == ["reset"]
|
runner.answer("0") # flipped the external switch
|
||||||
board(*BOOT, "I (20000) MICROPHONE: Read 300000 bytes in 4936 ms")
|
assert actions == [] # the firmware re-reads it; no reset
|
||||||
|
board("I (95000) MICROPHONE: Read 300000 bytes in 4936 ms")
|
||||||
assert runner.end.id == "mic_on"
|
assert runner.end.id == "mic_on"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mic_switch_no_effect_in_both_positions(rig):
|
||||||
|
engine, runner, clock, actions, board, _ = rig
|
||||||
|
_bench(engine)
|
||||||
|
runner.start("mic")
|
||||||
|
runner.answer("0")
|
||||||
|
board("W (95000) MICROPHONE: Microphone is in shutdown state, skipping sample collection")
|
||||||
|
assert runner.step.id == "flip_back"
|
||||||
|
runner.answer("0")
|
||||||
|
board("W (105000) MICROPHONE: Microphone is in shutdown state, skipping sample collection")
|
||||||
|
assert runner.end.id == "mic_switch_faulty"
|
||||||
|
|
||||||
|
|
||||||
def test_mic_clap_test_detects_dead_mic(rig):
|
def test_mic_clap_test_detects_dead_mic(rig):
|
||||||
engine, runner, clock, _, board, _ = rig
|
engine, runner, clock, _, board, _ = rig
|
||||||
board(*BOOT, *["I (20036) MICROPHONE: Sum SQ: 0.000000, RMS pressure: 0.000000, "
|
board(*BOOT, *["I (20036) MICROPHONE: Sum SQ: 0.000000, RMS pressure: 0.000000, "
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,6 +1,138 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.11, <3.14"
|
requires-python = ">=3.11, <3.14"
|
||||||
|
resolution-markers = [
|
||||||
|
"platform_machine == 'x86_64' and sys_platform == 'darwin'",
|
||||||
|
"platform_machine != 'x86_64' or sys_platform != 'darwin'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "appdirs"
|
||||||
|
version = "1.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argcomplete"
|
||||||
|
version = "3.7.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitarray"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/f7/6765577df59e2345036e435f7e983e1c291d67b7d76a51918eff04ad1494/bitarray-3.11.0.tar.gz", hash = "sha256:bf19437ec00ec3d40aef82eaeedc14cf4000be9b635c4f5049796506e6630dd8", size = 174911, upload-time = "2026-09-01T15:21:52.076Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/55/aeb2eb3870a31257a29d3830f7d5804253d2070727ef6475629455491d33/bitarray-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:470940879d8553fecddc3cf306b7d4b69fa0f5b7a6e3790b238fe553578061c1", size = 170436, upload-time = "2026-09-01T15:19:54.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/b4/38b6131ecd500f8f853367339b9e95ad4644d47729bcbf0fa2c5e4c06a91/bitarray-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56a2aa49e900aa927c147328e571da8a38a50ae99277ed093290845498dedefc", size = 166780, upload-time = "2026-09-01T15:19:55.173Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/28/d1e0493106911b08d3d328e463aa140ca76fcab6cdcda06912ffb59d53cf/bitarray-3.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55370f00c52b5e46800fee0218a27a1b99db6e09113c1b804190c039e4350ea1", size = 380740, upload-time = "2026-09-01T15:19:56.253Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/85/1ebb39e36b7102358942609583aa5a027b7e1eb83e140ad3fba86e53adba/bitarray-3.11.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:155205159f3c38180a705253341b8200414c0198e86a9b9e2db49010236ff785", size = 404566, upload-time = "2026-09-01T15:19:57.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/8d/e94bfa36a7cadcd16415afe531e0a01c6e2dc2bb11095c6c52fca384c642/bitarray-3.11.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c49fc5f57eea2e859a3f2936a1fed84510af6f7464572b641485de2d22148cb", size = 418899, upload-time = "2026-09-01T15:19:58.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/3a/859196ab78c6fa0aa8f04155308dd314265c8c27e09350bcd3894fc333d8/bitarray-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41545b21384fd91767d294a5b3526c6306f7db577cf59ba72b51498e90bd52a7", size = 383414, upload-time = "2026-09-01T15:19:59.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/fc/5fafae3bee337c31372293fcc6259960e9bcaf89825f07798514213c5686/bitarray-3.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:afe8ee837c085eeeaead62429f260a2de3a0602d1a34bcdead8b9f0388ef6114", size = 378658, upload-time = "2026-09-01T15:20:00.751Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/c8/39a1e59453b99a67ee449d9d0c378f5e282ec4c00645127a9aaa4a5adba7/bitarray-3.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4bddbc76e619a36d2abad14713c4dd970a8fa46a75a85ebbb626a2f26f1821d8", size = 401990, upload-time = "2026-09-01T15:20:01.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/aa/add67250e559e24fdc585921062e388395e0b47f40c6aa13e1ee493a13d4/bitarray-3.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:027766e71da6063cb51fcd4c51f031ac14d5088ecb55e4500ffd692d78630dd3", size = 399837, upload-time = "2026-09-01T15:20:03.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/80/c758fa566da3180e43fba1a9bc2c84b29fc5e25ce0149155378e89ee4172/bitarray-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:116a23f655f394f80f41fdc03368862485ac05534a56991eefb9f18f72e17f17", size = 380673, upload-time = "2026-09-01T15:20:04.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/8a/fcb7bef53c2aacdbc2ca07f498e5249ab7d87dbfed666f184bb8d16fec0f/bitarray-3.11.0-cp311-cp311-win32.whl", hash = "sha256:30597fa6f4116eb6e374568d4f4de7e220462368ec7ce37d93403871bdf27a8c", size = 160605, upload-time = "2026-09-01T15:20:05.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2c/3d3f3f7846432bc1f630c99fa5cd595674b5318c77c774b6c028bdbf05ad/bitarray-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7b16df7ddef03e30622d894cce86a560b7888415ddcac470eb99940a5adf1a35", size = 171179, upload-time = "2026-09-01T15:20:06.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/c8cb10050d1afed77e0511861ba49d56724fd846b4a0b596f52e7ebe5711/bitarray-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:ec3d8b99a92220bdba382acf4eff370d6a6cda34a5ce936726cb9e8cf2427c47", size = 166363, upload-time = "2026-09-01T15:20:07.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/74/c878e209fd15fdf0460763199892fc4e74c6a7345f5161ad673af586f2fe/bitarray-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:93a7f9f9f848ec79f19fdd39f3b8f750d9abf8e65cd6fb3a045df192de23855d", size = 170727, upload-time = "2026-09-01T15:20:08.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/f8/4463653a8c909796c8f8931d0fd64344c6745a144e432d0310435197b0ed/bitarray-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3889cf12ae0c04076772c8be66655bfe5925003b44dec056b569a68a121f7e93", size = 166746, upload-time = "2026-09-01T15:20:09.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/30/edaec4a0fffd04ee98b1425a597dc1d350c4def690b0a34815a75e5c2c33/bitarray-3.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:823638d0a900cea53c173fb31f5f1f4b913065a92fa3406d9b20eb7b7ebf189d", size = 383620, upload-time = "2026-09-01T15:20:10.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/1d/8fba2e884d5e5e7fa6828b4ca461e819259bac0f6f99073388157ea1c6d6/bitarray-3.11.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:301763ffde9d288c5a58dda51c5290bc1081c2bc0faf266c4aef227ca59004d5", size = 406908, upload-time = "2026-09-01T15:20:12.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/71/414853d582267d8e397ea5bf894503236d81660c5d7a34b2a0d0ec6e9f2d/bitarray-3.11.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:664f02e6e2c63fcc1c02718d2daa21b04f77a18ef3850a9b348597c0c8a0f222", size = 422031, upload-time = "2026-09-01T15:20:13.157Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/1c/35b3f34fee6f91f708eb81af03438afc137b2e5fc0e27ec11d948287f64c/bitarray-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52741a1d24a3723a8151259906cf1befeaf3d4ccf7d3e27e8be4334590b59bcd", size = 387692, upload-time = "2026-09-01T15:20:14.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/a9/7ebe3e04548cee84fba250425935a500ff02e7fef1b020ed182a3f5227c4/bitarray-3.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a1093eb1a756f08c7bdeb22969fb463aa62f036dc191ad371af5e292108c611", size = 381169, upload-time = "2026-09-01T15:20:15.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/48/cdf80b633f326451de801504f8718dd71ce63f534d29348822896d9c5bd2/bitarray-3.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ab0988829e4ed10fa971842e9fbf77595b8a4557a078a3589b0754f06417ddff", size = 404500, upload-time = "2026-09-01T15:20:16.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/cf/a64cab1bb0f48101ac45fb45a7f0d000af76e26ed0ab26b52573140a9a64/bitarray-3.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c4cabc2439ab3ce76881037741bf41e9fafda217ff721fdee92701e5fe3c8b9c", size = 402903, upload-time = "2026-09-01T15:20:18.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/b8/c3937572587a0503ea0a05ae61543916d6d93ffe48c26532cad9c4bfd9e4/bitarray-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9db61968efb834dacda6bcd7ef1f8a033253a53ac31426a684a28a7e4c9f2f29", size = 384582, upload-time = "2026-09-01T15:20:19.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/13/824648ef8f3fc14f56c4f9903e6c06979385f0c3a4d76371f6ccd8e9d1b2/bitarray-3.11.0-cp312-cp312-win32.whl", hash = "sha256:97cd5913000e4e56d211f7db053284d1f753aed34838368769bf084474a76f0a", size = 160902, upload-time = "2026-09-01T15:20:21.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/c4/0a1608187e6d21b30c341ebcce6f75efbc8fe289f87ad58aa4149ce59828/bitarray-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:5db38c0a36d07190c3629c65dab2504233a56b1c35d8b1cc3d7369943d028711", size = 171542, upload-time = "2026-09-01T15:20:22.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/59/f21b457eb6ab683ccf755093550ec1408e8ec8a66b980653c99740b85552/bitarray-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9aafe7dc6d568470f5c297d0b15acddd3afc1677c983f0460b28f8cac4e9360c", size = 166561, upload-time = "2026-09-01T15:20:23.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/3d/578c4b63b3c1f206dfe7f7f60e941ca2d0aafd9da43943c076c01e976ec0/bitarray-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ac322a81c3c938c13093d8d77c419093f50118c29fbfa9eb80813d93af1a2d95", size = 170422, upload-time = "2026-09-01T15:20:24.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/30/3f982be25cf0c858351490b6d0cf6168e8668bb81e3daac25d8a3b2b8e44/bitarray-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ece8caff2fb31d6c69430d3dfc0efff70083e66b012f7f5de60cfe5dca98ba87", size = 166475, upload-time = "2026-09-01T15:20:25.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/ad/f94c267873f0c80705dcef602af0a8d3f42838197e8fff01033f2a8084c9/bitarray-3.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:962dc3e5622a08bb1c1ad429a9f7dace7efadd7fef1fcdf1628a6db142333256", size = 381856, upload-time = "2026-09-01T15:20:26.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/ce/e9ba5d080302c4d6fa269bccc7ad6ed4a5ad36a1dc75baae334714bbc183/bitarray-3.11.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de25b8c76fe9fe7d9c6bce2832096f020133e1d21e2841abd7280937bf90e56e", size = 405246, upload-time = "2026-09-01T15:20:27.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/4c/cce77df63010304704eb802d884d9b11c84f63a02d7c4081a41225e25343/bitarray-3.11.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86dfaab663a2ad16ed2f6252116cf034cfc84282aa744f19a9a66542e85c9ca9", size = 420083, upload-time = "2026-09-01T15:20:29.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/1f/2c7b1a995af9814b6ec5ee69333a0a6131924ed0f3f10ffbd73c22c0a6ad/bitarray-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5605c8e74f95f77dd1b93c8c83d6fefe0d41d2704683d4a72152ca5284e2546d", size = 385572, upload-time = "2026-09-01T15:20:30.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/58/53845d1ddbe6077b61e7f39f586f7f35f18ce7dc47f178331e4fd64f59e1/bitarray-3.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d62e73fcdd273d07b1e9699de0e66e4d367b28c554c86bfe7c92029337c2a414", size = 379323, upload-time = "2026-09-01T15:20:31.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/66/b67544886466ede3d26e58c43d9841cc427d4fdcd81ded4087313687241a/bitarray-3.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9808734d1e5fd2839660a9db1abfe93e9d64b2aa9ed59aa480193ad93e8d620e", size = 402087, upload-time = "2026-09-01T15:20:33.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/a7/4ffc5984f3f4a12458adbceff60def49752f74807945771428f97694aec0/bitarray-3.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8b77aa18131418b869be882df1c5e229fb02f90861783bbe638d9ebe296e9209", size = 401000, upload-time = "2026-09-01T15:20:34.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/15/2d5dd4107e49b2c0040bc9cc48ee5af7f45772fedae3bc54fa987aeeedf2/bitarray-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d084fa59c94d3fec7eac6793e08162fa697aa4e70b9f519618c29d3e54eda752", size = 382431, upload-time = "2026-09-01T15:20:36.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/8b/8d95483688bcf297654c87b65ef1847fad794adb083d294da65ac275d4fc/bitarray-3.11.0-cp313-cp313-win32.whl", hash = "sha256:387b64dc54be3732a0988bf3731fc06d7d4f52dc9470d1a5479e8ce2b67d368b", size = 160760, upload-time = "2026-09-01T15:20:37.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/9b/b10ebf5e9a479815d2d54908617c0c55b255965389d3132927c9cd9ccf86/bitarray-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:b4afbb464a1db2d37858bd3b87bd59a76b4aae5e29ec007a9fd00bf5a911e1da", size = 171278, upload-time = "2026-09-01T15:20:38.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/3a/b0a29fc2379c567eb49730022c5913ad85b2f9ccda1b841737f756e1fb87/bitarray-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:36da946f64e10e28b23f345fde90f4d14e9c2e9013364fe04d9b2013e7291285", size = 166426, upload-time = "2026-09-01T15:20:39.862Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitstring"
|
||||||
|
version = "4.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "bitarray" },
|
||||||
|
{ name = "tibs" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/36/d3/de6fe4e7065df8c2f1ac1766f5fdccbe75bc18af2cf2dbeecd34d68e1518/bitstring-4.4.0.tar.gz", hash = "sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37", size = 255209, upload-time = "2026-03-10T20:29:14.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/02/1a870bab76f2896d827aa4963be95e56675ffa1453e53525d13c43036edf/bitstring-4.4.0-py3-none-any.whl", hash = "sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737", size = 76846, upload-time = "2026-03-10T20:29:12.832Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
@@ -11,6 +143,93 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "48.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecdsa"
|
||||||
|
version = "0.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "esptool"
|
||||||
|
version = "4.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "argcomplete", marker = "sys_platform != 'win32'" },
|
||||||
|
{ name = "bitstring" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "ecdsa" },
|
||||||
|
{ name = "intelhex" },
|
||||||
|
{ name = "pyserial" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "reedsolo" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/84/19/f68b27fd43564d69277e956d13b8c2b352e7f1af4fb5d6e5c43e5419e61c/esptool-4.12.0.tar.gz", hash = "sha256:d797f6529d582173508baf0572542ddadf53aae8c92c84ea9f420f242464103c", size = 463840, upload-time = "2026-07-14T08:36:51.891Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs"
|
||||||
|
version = "2.4.16"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "appdirs" },
|
||||||
|
{ name = "setuptools" },
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261, upload-time = "2022-05-02T09:25:52.363Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -20,28 +239,47 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "intelhex"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/37/1e7522494557d342a24cb236e2aec5d078fac8ed03ad4b61372586406b01/intelhex-2.3.0.tar.gz", hash = "sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093", size = 44513, upload-time = "2020-10-20T20:35:51.526Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/78/79461288da2b13ed0a13deb65c4ad1428acb674b95278fa9abf1cefe62a2/intelhex-2.3.0-py2.py3-none-any.whl", hash = "sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4", size = 50914, upload-time = "2020-10-20T20:35:50.162Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kota-technician-tool"
|
name = "kota-technician-tool"
|
||||||
version = "0.1.0"
|
version = "0.6.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "esptool" },
|
||||||
{ name = "pyserial" },
|
{ name = "pyserial" },
|
||||||
{ name = "pyside6-essentials" },
|
{ name = "pyside6-essentials" },
|
||||||
|
{ name = "segno" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pyfatfs" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "setuptools" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "esptool", specifier = "~=4.8" },
|
||||||
{ name = "pyserial", specifier = ">=3.5" },
|
{ name = "pyserial", specifier = ">=3.5" },
|
||||||
{ name = "pyside6-essentials", specifier = ">=6.7" },
|
{ name = "pyside6-essentials", specifier = ">=6.7" },
|
||||||
|
{ name = "segno", specifier = ">=1.6" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
dev = [
|
||||||
|
{ name = "pyfatfs", specifier = ">=1.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8" },
|
||||||
|
{ name = "setuptools", specifier = "<81" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
@@ -61,6 +299,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
@@ -110,6 +369,70 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reedsolo"
|
||||||
|
version = "1.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/61/a67338cbecf370d464e71b10e9a31355f909d6937c3a8d6b17dd5d5beb5e/reedsolo-1.7.0.tar.gz", hash = "sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732", size = 59723, upload-time = "2023-01-17T05:10:19.733Z" }
|
||||||
|
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]]
|
[[package]]
|
||||||
name = "shiboken6"
|
name = "shiboken6"
|
||||||
version = "6.11.2"
|
version = "6.11.2"
|
||||||
@@ -121,3 +444,35 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tibs"
|
||||||
|
version = "0.5.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/cd/6cf028decf1c2df4d26077dd5d0532587d93d4917233d5e004133166a940/tibs-0.5.7.tar.gz", hash = "sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb", size = 78255, upload-time = "2026-03-12T13:06:29.79Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/2d/de2c579d3eea0f18212b5b16decb04568b7a0ef912d00581a77492609d4e/tibs-0.5.7-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c", size = 411352, upload-time = "2026-03-12T13:06:52.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/71/4c21ccc5c2e1672f9cd91ed2c46604c250cffd9d386113772dded128b5cf/tibs-0.5.7-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44", size = 383971, upload-time = "2026-03-12T13:06:50.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/85/399940ac5393772792a209911a5efa42cf55cf621771e48b863211ac5a2a/tibs-0.5.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f", size = 416256, upload-time = "2026-03-12T13:06:24.222Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/94/481a73e74d398949f57d297b1809a10a951d252e7ec94b6715ed952ce500/tibs-0.5.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f", size = 428003, upload-time = "2026-03-12T13:06:23.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/e0/72db1760a7f7fec1d5f3690e0855fbbccbcf0a4a2fd318c9d71f3b33f3a7/tibs-0.5.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98", size = 455589, upload-time = "2026-03-12T13:06:53.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/26/9cd3395914bf705d6ae1e9a6c323f727e9dc88fef716327ce7f486e0b55a/tibs-0.5.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3", size = 459266, upload-time = "2026-03-12T13:06:21.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/3b/267f19a008d13c704dc0b044138a56239272a43531ccb05464129d0fbd01/tibs-0.5.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41", size = 423466, upload-time = "2026-03-12T13:06:41.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/d4/424ae3515e0e013ad83186074bf3beb53399b9052c00da703415ccc316ca/tibs-0.5.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e", size = 452080, upload-time = "2026-03-12T13:06:32.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/15/ab80beba83a134745439d33763e1d3b017f994abeb9c309a3ac9fd94e90e/tibs-0.5.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54", size = 592311, upload-time = "2026-03-12T13:06:47.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/21/f5cf41c15431e63aeaefb494e714d48d9e9061b4e01fcc01d1987e2e5faa/tibs-0.5.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392", size = 703400, upload-time = "2026-03-12T13:06:16.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/ec/b3bdb7dcc3de8513c5678a685f4e25bb85ef48526d7d535ddc592f9e8602/tibs-0.5.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452", size = 664623, upload-time = "2026-03-12T13:06:48.894Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/71/7b85af3ad1b2cd9871c8f50ba0eb17e54e12481b467678535e58aced0d98/tibs-0.5.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b", size = 635199, upload-time = "2026-03-12T13:06:34.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/63/60220fb502beb857306afd4a5bac4a8617ae496f3b1f4968d127380fdefe/tibs-0.5.7-cp38-abi3-win32.whl", hash = "sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2", size = 288454, upload-time = "2026-03-12T13:06:30.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/ab/aab78827ba7e0d65fe346b86d1d61e0792c38d5f9b7547e0f71b7027c835/tibs-0.5.7-cp38-abi3-win_amd64.whl", hash = "sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2", size = 304135, upload-time = "2026-03-12T13:06:35.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/59/e9e6a610928a4bcbf04f0ac1436ee320aa8cbe95181f1aa32687c50e858b/tibs-0.5.7-cp38-abi3-win_arm64.whl", hash = "sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7", size = 289272, upload-time = "2026-03-12T13:06:19.247Z" },
|
||||||
|
]
|
||||||
Reference in new issue
Block a user