Compare commits
7
Commits
v0.3
..
724c2d62f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
724c2d62f9 | ||
|
|
43f4c51aa4 | ||
|
|
bfa7edce6f | ||
|
|
dd915b5a74 | ||
|
|
22bd853ecf | ||
|
|
f5b32e1554 | ||
|
|
72adfe1cec |
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
# KOTA Sensorbox Technician Tool
|
||||||
|
|
||||||
|
Diagnoza sensorboxa przez USB, wgrywanie firmware z folderu `firmware/` i przygotowanie karty SD.
|
||||||
|
Działa na Windows 10/11 i macOS 13 (Ventura) lub nowszym, na Macach z Intelem i Apple Silicon.
|
||||||
|
|
||||||
|
Repozytorium: https://gitea.kota-app.com/kkallas/kota-technician-tool (dostęp nadaje właściciel repozytorium).
|
||||||
|
|
||||||
|
## Instalacja
|
||||||
|
|
||||||
|
Wybierz jeden sposób. Nie trzymaj narzędzia w folderze Google Drive ani OneDrive.
|
||||||
|
|
||||||
|
### A. Paczka zip (najprościej)
|
||||||
|
|
||||||
|
1. Na Gitea otwórz **Releases** i przy najnowszej wersji pobierz **Source Code (ZIP)**.
|
||||||
|
2. Rozpakuj do zwykłego folderu, np. `Dokumenty\KOTA\technician-tool`.
|
||||||
|
3. Nowa wersja: pobierz nowy zip i rozpakuj obok (albo w miejsce starego folderu).
|
||||||
|
|
||||||
|
### B. Git (aktualizacja jednym poleceniem)
|
||||||
|
|
||||||
|
Wymaga zainstalowanego gita (Windows: https://git-scm.com, macOS: `xcode-select --install`).
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://gitea.kota-app.com/kkallas/kota-technician-tool.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Przy pierwszym połączeniu podaj login i hasło (lub token) do Gitea; system je zapamięta.
|
||||||
|
Aktualizacja do najnowszej wersji, w folderze `kota-technician-tool`:
|
||||||
|
|
||||||
|
```
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Konkretna wersja, np. na potrzeby serwisu starszego boxa: `git switch --detach v0.8`, powrót: `git switch main`.
|
||||||
|
|
||||||
|
## Uruchomienie
|
||||||
|
|
||||||
|
**Windows:** dwuklik `run_windows.bat`.
|
||||||
|
|
||||||
|
**macOS:** dwuklik `run_macos.command`. Jeśli Finder odpowie, że pliku nie można uruchomić (zdarza się po
|
||||||
|
rozpakowaniu zip), otwórz Terminal, wpisz `bash ` (ze spacją), przeciągnij plik do okna i naciśnij Enter.
|
||||||
|
Na stałe naprawia to `chmod +x run_macos.command`. Przy instalacji przez git ten problem nie występuje.
|
||||||
|
|
||||||
|
Przy pierwszym uruchomieniu skrypt proponuje instalację `uv` (menedżer Pythona, tylko dla bieżącego użytkownika)
|
||||||
|
i pobiera biblioteki. Potrzebny jest internet, trwa to 1–2 minuty. Kolejne starty są natychmiastowe.
|
||||||
|
Środowisko Pythona jest poza folderem narzędzia:
|
||||||
|
`%LOCALAPPDATA%\KotaTechnicianTool\venv-<wersja>` (Windows), `~/Library/Caches/KotaTechnicianTool/venv-<wersja>` (macOS).
|
||||||
|
|
||||||
|
Sesje, kopie zapasowe flash i ustawienia są w `~/KotaTechnicianTool` (Windows: `C:\Users\<użytkownik>\KotaTechnicianTool`).
|
||||||
|
|
||||||
|
## Port sensorboxa
|
||||||
|
|
||||||
|
- Windows: `COMx - Sensorbox (native USB) [MAC]`
|
||||||
|
- macOS: `/dev/cu.usbmodemXXXX - Sensorbox (native USB) [MAC]`
|
||||||
|
|
||||||
|
Kabel USB musi przenosić dane (nie tylko ładowanie). Przy restartach z komunikatem o brownout użyj innego portu
|
||||||
|
lub zasilacza.
|
||||||
|
|
||||||
|
## Firmware
|
||||||
|
|
||||||
|
Wgrać można tylko pliki `.bin` z folderu `firmware/`. Lista pokazuje datę kompilacji zapisaną w pliku,
|
||||||
|
najnowszy build jest na górze. Nowy build: wrzuć plik do `firmware/` i kliknij **Refresh list**.
|
||||||
|
|
||||||
|
## Dla zespołu
|
||||||
|
|
||||||
|
- Praca nad zmianą: gałąź (`git switch -c feature/<nazwa>`) → commity z przechodzącymi testami →
|
||||||
|
`git push -u origin feature/<nazwa>` → pull request na Gitea → merge do `main`.
|
||||||
|
- Wydanie wersji: numer w `pyproject.toml`, `kota_tech/__init__.py` i skryptach `run_*`, tag `vX.Y`
|
||||||
|
(`git tag -a vX.Y -m "Technician tool vX.Y"`, `git push origin vX.Y`), opis w **Releases** na Gitea.
|
||||||
|
- Nie commitujemy `ENV.TXT` z prawdziwym tokenem ani haseł Wi-Fi.
|
||||||
|
- Testy:
|
||||||
|
|
||||||
|
```
|
||||||
|
uv run --frozen pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Zmiany w wersjach: `docs/CHANGES-v0.7.md`, `docs/CHANGES-v0.8.md`; historia v0.1–v0.8 jest w tagach gita.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Technician tool v0.7 - changes
|
||||||
|
|
||||||
|
## Serial reading (firmware 1.0.16+, see firmware-diagnostics-what-changed.md)
|
||||||
|
- `KOTA: <record> key=value` lines are parsed into fields (`core/logline.py`, kind `KOTA`) and interpreted
|
||||||
|
directly by the engine (`diag/engine.py` `_kota_*`, knowledge in `diag/kota.py`) instead of regex rules:
|
||||||
|
boot, i2c, rtc, sd, matter, mic, health, status, led, restart.
|
||||||
|
- Exact status LED (colour + pattern + meaning) from `led` lines; `status` lines give the reason and a
|
||||||
|
status finding that is resolved when the status changes. Cyan LED supported.
|
||||||
|
- Restart cause from `last_restart=` / `restart reason=`; state before a restart from `led previous_boot`.
|
||||||
|
- Error IDs `[E-XX-NN]`: looked up in `diag/error_ids.csv` (copy of the firmware's docs/error_ids.csv);
|
||||||
|
shown with the technician action. Old message rules still match (ID prefix is stripped for matching).
|
||||||
|
- Diagnosis panel: new "Box self-report" section; log view filter "KOTA diagnostics + errors".
|
||||||
|
- Matter QR dialog uses the exact QR/manual code reported by the box.
|
||||||
|
- Older firmware (v1.0) is still diagnosed as before.
|
||||||
|
|
||||||
|
## Flashing
|
||||||
|
- Only `.bin` files in the tool's `firmware/` folder can be flashed (import / developer library removed,
|
||||||
|
no manifest needed). The list shows the compile date read from the firmware itself, newest first.
|
||||||
|
- "Refresh list" and "Open firmware folder" buttons; broken or duplicate files are listed as "Not offered".
|
||||||
|
- Flash verification compares the 8-digit build hash from the `KOTA: boot` line.
|
||||||
|
|
||||||
|
Note: the KOTA lines are tested against lines built from the firmware source; the firmware has not yet
|
||||||
|
been confirmed on hardware.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Technician tool v0.8 - changes
|
||||||
|
|
||||||
|
## Windows + macOS
|
||||||
|
- Dependencies verified to install on macOS arm64 and x86_64 (uv.lock: PySide6 6.11 universal2 wheels,
|
||||||
|
requires macOS 13+); Windows unchanged.
|
||||||
|
- SD card check: macOS `diskutil` reports FAT32 partitions as `DOS_FAT_32` / `Windows_FAT_32`; these are now
|
||||||
|
accepted (previously a correct card was reported as "not FAT32" on a Mac).
|
||||||
|
- Unit tests for the macOS serial-port filter (`/dev/cu.*` only) and FAT32 names.
|
||||||
|
- README.md with launch steps for both systems (incl. the Google Drive executable-bit workaround on macOS);
|
||||||
|
docs/mac_agent_command.txt updated for a v0.8 test run on a Mac.
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
- Harmless Wi-Fi start-up errors are ignored (rule `benign.wifi_start`): `wifi:sta is connecting, return error`,
|
||||||
|
`esp_wifi_connect() failed: ESP_ERR_WIFI_CONN`, `Failed to get configured network when updating network
|
||||||
|
status` (unpaired box).
|
||||||
|
- Brown-out resets are detected (`E BOD: Brownout detector was triggered`, ROM reports RTC_SW_SYS_RST).
|
||||||
|
- Service report note = the Diagnosis panel as text (LED, firmware, box self-report, start-up progress with
|
||||||
|
times, findings with "Seen Nx, first at ... s, last at ... s" and log line), shortened to fit the service
|
||||||
|
form's 5000 characters.
|
||||||
|
|
||||||
|
Not yet run on a real Mac: see docs/mac_agent_command.txt.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
You are testing the KOTA sensorbox technician tool v0.8 on macOS. The current folder is
|
||||||
|
004-technician_tool/v0.8 (a Python/PySide6 desktop app; tested on Windows 11, macOS paths are covered by
|
||||||
|
unit tests but not yet run on a real Mac).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Python only via uv or conda/mamba. Never use bare/system python or pip.
|
||||||
|
- Do not build or modify firmware. Flash only files from the tool's firmware/ folder.
|
||||||
|
- 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.
|
||||||
|
Also check whether a double-click in Finder works (Google Drive may drop the executable bit).
|
||||||
|
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8" uv run --frozen pytest -q`
|
||||||
|
(expect 172 passed).
|
||||||
|
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
|
||||||
|
entry with a MAC (no /dev/tty.* duplicate). Connect, press "Reset & capture": the log starts at
|
||||||
|
"ESP-ROM:esp32s3" and the Diagnosis panel shows firmware v1.0.16 (build 4f10989e), the "Box self-report"
|
||||||
|
section and the LED with its pattern. Check whether the port disappears after the reset and whether the
|
||||||
|
tool reconnects. Wait 2 minutes: a "KOTA: health" line appears every 60 s.
|
||||||
|
4. Show filter "KOTA diagnostics + errors" shows only KOTA lines and errors.
|
||||||
|
5. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
|
||||||
|
6. Flash (App only, the newest file in firmware/, chosen by its compile date): the tool must enter flashing mode
|
||||||
|
by itself, write, restart the box and report "Success ... build 4f10989e". Capture the esptool output.
|
||||||
|
7. SD card tab: the card reader and card are listed (internal disks must NOT be listed). "Check card" must NOT
|
||||||
|
report a wrong file system for a FAT32 card. After my OK, "Format card and write ENV.TXT" must use
|
||||||
|
`diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT and eject the card.
|
||||||
|
8. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=...; the note in
|
||||||
|
the form must match the Diagnosis panel (self-report, start-up progress with times, findings with
|
||||||
|
"Seen Nx, first at ... s").
|
||||||
|
|
||||||
|
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
|
||||||
|
suggested fixes (file:line).
|
||||||
@@ -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.8.0"
|
||||||
+202
-146
@@ -1,146 +1,202 @@
|
|||||||
"""Serial byte stream -> framed, cleaned, parsed log lines.
|
"""Serial byte stream -> framed, cleaned, parsed log lines.
|
||||||
|
|
||||||
ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally
|
ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally
|
||||||
wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset
|
wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset
|
||||||
reason) has no level prefix and is classified separately, since a boot
|
reason) has no level prefix and is classified separately, since a boot
|
||||||
banner is the only reliable reboot marker.
|
banner is the only reliable reboot marker.
|
||||||
"""
|
|
||||||
|
Firmware 1.0.16+ adds structured diagnostic lines logged under the "KOTA"
|
||||||
from __future__ import annotations
|
tag ("I (812) KOTA: boot fw=1.0.16 build=4f10989e ... last_restart=none")
|
||||||
|
and prefixes every ESP_LOGE message with a stable error ID ("[E-SD-03] ").
|
||||||
import re
|
Both are parsed here so the diagnostics engine reads fields directly
|
||||||
import time
|
instead of matching message text.
|
||||||
from dataclasses import dataclass, field
|
"""
|
||||||
from enum import Enum
|
|
||||||
|
from __future__ import annotations
|
||||||
from .redact import redact
|
|
||||||
|
import re
|
||||||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
import time
|
||||||
ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
|
from dataclasses import dataclass, field
|
||||||
# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then
|
from enum import Enum
|
||||||
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
|
|
||||||
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
|
from .redact import redact
|
||||||
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
|
|
||||||
PANIC_RE = re.compile(
|
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|"
|
ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
|
||||||
r"^Backtrace:|Rebooting\.\.\.$"
|
# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then
|
||||||
)
|
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
|
||||||
|
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
|
||||||
|
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
|
||||||
class LineKind(str, Enum):
|
# "[E-SD-03] Failed to initialize the card ..." (firmware error_ids.csv).
|
||||||
LOG = "log" # ESP_LOGx line
|
ERROR_ID_RE = re.compile(r"^\[(E-[A-Z0-9]+-\d{2,})\] ?")
|
||||||
BOOT = "boot" # ROM boot banner (start of a new boot)
|
KOTA_TAG = "KOTA"
|
||||||
RESET = "reset" # ROM reset-reason line
|
PANIC_RE = re.compile(
|
||||||
PANIC = "panic" # crash / abort output
|
r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|"
|
||||||
RAW = "raw" # anything else (task list rows, line protocol, ...)
|
r"^Backtrace:|Rebooting\.\.\.$"
|
||||||
HOST = "host" # event generated by this tool (connect, reset, ...)
|
)
|
||||||
|
|
||||||
|
|
||||||
def host_event(text: str) -> LogLine:
|
class LineKind(str, Enum):
|
||||||
return LogLine(f"--- {text} ---", LineKind.HOST)
|
LOG = "log" # ESP_LOGx line
|
||||||
|
BOOT = "boot" # ROM boot banner (start of a new boot)
|
||||||
|
RESET = "reset" # ROM reset-reason line
|
||||||
@dataclass(frozen=True)
|
PANIC = "panic" # crash / abort output
|
||||||
class LogLine:
|
KOTA = "kota" # structured "KOTA: <record> key=value ..." line
|
||||||
text: str # cleaned + redacted
|
RAW = "raw" # anything else (task list rows, line protocol, ...)
|
||||||
kind: LineKind
|
HOST = "host" # event generated by this tool (connect, reset, ...)
|
||||||
host_time: float = field(default_factory=time.time)
|
|
||||||
level: str | None = None # E/W/I/D/V for LOG lines
|
|
||||||
uptime_ms: int | None = None
|
def host_event(text: str) -> LogLine:
|
||||||
tag: str | None = None
|
return LogLine(f"--- {text} ---", LineKind.HOST)
|
||||||
message: str | None = None
|
|
||||||
reset_reason: str | None = None
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LogLine:
|
||||||
def clean(raw: str) -> str:
|
text: str # cleaned + redacted
|
||||||
return ANSI_RE.sub("", raw).replace("\r", "").rstrip()
|
kind: LineKind
|
||||||
|
host_time: float = field(default_factory=time.time)
|
||||||
|
level: str | None = None # E/W/I/D/V for LOG lines
|
||||||
def parse_line(raw: str, host_time: float | None = None) -> LogLine:
|
uptime_ms: int | None = None
|
||||||
text = redact(clean(raw))
|
tag: str | None = None
|
||||||
t = time.time() if host_time is None else host_time
|
message: str | None = None
|
||||||
if m := ESP_LOG_RE.match(text):
|
reset_reason: str | None = None
|
||||||
level, ms, tag, msg = m.groups()
|
error_id: str | None = None # "E-SD-03" when the message carries one
|
||||||
return LogLine(text, LineKind.LOG, t, level, int(ms), tag.strip(), msg)
|
record: str | None = None # KOTA lines: "boot", "i2c", "health", ...
|
||||||
if BOOT_BANNER_RE.match(text):
|
fields: dict[str, str] = field(default_factory=dict) # KOTA key=value pairs
|
||||||
return LogLine(text, LineKind.BOOT, t)
|
words: tuple[str, ...] = () # KOTA bare words ("verify", "OK", "->", ...)
|
||||||
if m := RESET_REASON_RE.match(text):
|
|
||||||
return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2))
|
@property
|
||||||
if PANIC_RE.search(text):
|
def match_text(self) -> str:
|
||||||
return LogLine(text, LineKind.PANIC, t)
|
"""Text for message rules: the error-ID prefix removed, so rules
|
||||||
return LogLine(text, LineKind.RAW, t)
|
written for older firmware keep matching."""
|
||||||
|
if self.error_id and self.message is not None and self.tag:
|
||||||
|
return self.text.replace(f"[{self.error_id}] ", "", 1)
|
||||||
class LineFramer:
|
return self.text
|
||||||
"""Accumulates bytes and yields complete lines.
|
|
||||||
|
|
||||||
A partial line is released after `idle_flush_s` without new bytes so
|
def parse_kota(body: str) -> tuple[str, dict[str, str], tuple[str, ...]]:
|
||||||
prompts / unterminated output are not held back forever.
|
"""'rtc verify running=1 delta=2s ok=1' -> ('rtc', {...}, ('verify',)).
|
||||||
|
|
||||||
Lines are also split where a new log line starts in the middle
|
Values never contain spaces. The mic line has a parenthesised aside
|
||||||
("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores
|
("(GPIO3=1, expected 1)"): brackets and commas are stripped from keys
|
||||||
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old
|
and values, and the aside's words are kept as bare words.
|
||||||
line while no host reads, releasing it glued in front of the next live
|
"""
|
||||||
output. After a (re)open, the fragment in front of the first such split
|
tokens = body.split()
|
||||||
is stale and dropped. Timing cannot identify it, because the FIFO only
|
if not tokens:
|
||||||
flushes when the board next prints, possibly seconds later.
|
return "", {}, ()
|
||||||
"""
|
fields: dict[str, str] = {}
|
||||||
|
words: list[str] = []
|
||||||
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
|
for tok in tokens[1:]:
|
||||||
drop_first_partial: bool = False):
|
key, eq, value = tok.partition("=")
|
||||||
self._buf = bytearray()
|
if eq and key.strip("(,"):
|
||||||
self._last_rx = 0.0
|
key = key.lstrip("(")
|
||||||
self.idle_flush_s = idle_flush_s
|
# "0x68=DS1307(missing)" keeps its bracket; "(GPIO3=1," loses it.
|
||||||
self.max_line = max_line
|
value = value.rstrip(",")
|
||||||
self._first_line_pending = drop_first_partial
|
if tok.startswith("(") or value.endswith(")") and "(" not in value:
|
||||||
|
value = value.rstrip(")")
|
||||||
def cancel_drop(self) -> None:
|
fields[key] = value
|
||||||
self._first_line_pending = False
|
else:
|
||||||
|
words.append(tok.strip("(),"))
|
||||||
def feed(self, data: bytes, now: float | None = None) -> list[str]:
|
return tokens[0], fields, tuple(w for w in words if w)
|
||||||
now = time.monotonic() if now is None else now
|
|
||||||
if data:
|
|
||||||
self._buf.extend(data)
|
def clean(raw: str) -> str:
|
||||||
self._last_rx = now
|
return ANSI_RE.sub("", raw).replace("\r", "").rstrip()
|
||||||
out: list[str] = []
|
|
||||||
while (i := self._buf.find(b"\n")) >= 0:
|
|
||||||
out += self._emit(self._buf[:i])
|
def parse_line(raw: str, host_time: float | None = None) -> LogLine:
|
||||||
del self._buf[: i + 1]
|
text = redact(clean(raw))
|
||||||
if len(self._buf) >= self.max_line:
|
t = time.time() if host_time is None else host_time
|
||||||
out += self._emit(self._buf)
|
if m := ESP_LOG_RE.match(text):
|
||||||
self._buf.clear()
|
level, ms, tag, msg = m.groups()
|
||||||
return out
|
tag = tag.strip()
|
||||||
|
if tag == KOTA_TAG:
|
||||||
def poll_idle(self, now: float | None = None) -> list[str]:
|
record, fields, words = parse_kota(msg)
|
||||||
now = time.monotonic() if now is None else now
|
return LogLine(text, LineKind.KOTA, t, level, int(ms), tag, msg,
|
||||||
if self._buf and now - self._last_rx >= self.idle_flush_s:
|
record=record, fields=fields, words=words)
|
||||||
return self.flush()
|
eid = None
|
||||||
return []
|
if em := ERROR_ID_RE.match(msg):
|
||||||
|
eid, msg = em.group(1), msg[em.end():]
|
||||||
def flush(self) -> list[str]:
|
return LogLine(text, LineKind.LOG, t, level, int(ms), tag, msg, error_id=eid)
|
||||||
if not self._buf:
|
if BOOT_BANNER_RE.match(text):
|
||||||
return []
|
return LogLine(text, LineKind.BOOT, t)
|
||||||
out = self._emit(self._buf)
|
if m := RESET_REASON_RE.match(text):
|
||||||
self._buf.clear()
|
return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2))
|
||||||
return out
|
if PANIC_RE.search(text):
|
||||||
|
return LogLine(text, LineKind.PANIC, t)
|
||||||
def _emit(self, raw: bytes | bytearray) -> list[str]:
|
return LogLine(text, LineKind.RAW, t)
|
||||||
parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
|
|
||||||
if self._first_line_pending:
|
|
||||||
self._first_line_pending = False
|
class LineFramer:
|
||||||
if len(parts) > 1:
|
"""Accumulates bytes and yields complete lines.
|
||||||
parts = parts[1:] # stale FIFO fragment from before the open
|
|
||||||
return parts
|
A partial line is released after `idle_flush_s` without new bytes so
|
||||||
|
prompts / unterminated output are not held back forever.
|
||||||
|
|
||||||
# Start of a log line (optionally ANSI-coloured) or ROM banner.
|
Lines are also split where a new log line starts in the middle
|
||||||
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)")
|
("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores
|
||||||
|
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old
|
||||||
|
line while no host reads, releasing it glued in front of the next live
|
||||||
def split_glued(text: str) -> list[str]:
|
output. After a (re)open, the fragment in front of the first such split
|
||||||
# Left-to-right scan so an ANSI colour prefix stays with its line.
|
is stale and dropped. Timing cannot identify it, because the FIFO only
|
||||||
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0]
|
flushes when the board next prints, possibly seconds later.
|
||||||
if not cuts:
|
"""
|
||||||
return [text]
|
|
||||||
bounds = [0, *cuts, len(text)]
|
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
|
||||||
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b]
|
drop_first_partial: bool = False):
|
||||||
|
self._buf = bytearray()
|
||||||
|
self._last_rx = 0.0
|
||||||
|
self.idle_flush_s = idle_flush_s
|
||||||
|
self.max_line = max_line
|
||||||
|
self._first_line_pending = drop_first_partial
|
||||||
|
|
||||||
|
def cancel_drop(self) -> None:
|
||||||
|
self._first_line_pending = False
|
||||||
|
|
||||||
|
def feed(self, data: bytes, now: float | None = None) -> list[str]:
|
||||||
|
now = time.monotonic() if now is None else now
|
||||||
|
if data:
|
||||||
|
self._buf.extend(data)
|
||||||
|
self._last_rx = now
|
||||||
|
out: list[str] = []
|
||||||
|
while (i := self._buf.find(b"\n")) >= 0:
|
||||||
|
out += self._emit(self._buf[:i])
|
||||||
|
del self._buf[: i + 1]
|
||||||
|
if len(self._buf) >= self.max_line:
|
||||||
|
out += self._emit(self._buf)
|
||||||
|
self._buf.clear()
|
||||||
|
return out
|
||||||
|
|
||||||
|
def poll_idle(self, now: float | None = None) -> list[str]:
|
||||||
|
now = time.monotonic() if now is None else now
|
||||||
|
if self._buf and now - self._last_rx >= self.idle_flush_s:
|
||||||
|
return self.flush()
|
||||||
|
return []
|
||||||
|
|
||||||
|
def flush(self) -> list[str]:
|
||||||
|
if not self._buf:
|
||||||
|
return []
|
||||||
|
out = self._emit(self._buf)
|
||||||
|
self._buf.clear()
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _emit(self, raw: bytes | bytearray) -> list[str]:
|
||||||
|
parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
|
||||||
|
if self._first_line_pending:
|
||||||
|
self._first_line_pending = False
|
||||||
|
if len(parts) > 1:
|
||||||
|
parts = parts[1:] # stale FIFO fragment from before the open
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
# Start of a log line (optionally ANSI-coloured) or ROM banner.
|
||||||
|
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)")
|
||||||
|
|
||||||
|
|
||||||
|
def split_glued(text: str) -> list[str]:
|
||||||
|
# Left-to-right scan so an ANSI colour prefix stays with its line.
|
||||||
|
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0]
|
||||||
|
if not cuts:
|
||||||
|
return [text]
|
||||||
|
bounds = [0, *cuts, len(text)]
|
||||||
|
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b]
|
||||||
+999
-576
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,193 @@
|
|||||||
|
id,module,severity,message,action
|
||||||
|
E-SD-01,SD,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-SD-02,SD,error,"Failed to mount filesystem. If you want the card to be formatted, set the CONFIG_EXAMPLE_FORMAT_IF_MOUNT_FAILED menuconfig option.",SD card is not FAT formatted or is corrupt; reformat it as FAT32 and restore ENV.TXT
|
||||||
|
E-SD-03,SD,error,Failed to initialize the card (%s). Make sure SD card lines have pull-up resistors in place.,Reseat the SD card; if it persists replace the card or check the SD connector
|
||||||
|
E-SD-04,SD,error,Failed to stat %s,Reseat the SD card; if it persists replace the card or check the SD connector
|
||||||
|
E-SD-05,SD,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-ENV-01,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-ENV-02,ENV,error,Failed to open .env file for reading,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||||
|
E-ENV-03,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-ENV-04,ENV,error,Failed to open existing .env file for reading; aborting write to avoid losing other variables,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||||
|
E-ENV-05,ENV,error,Failed to open .env file for writing,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||||
|
E-ENV-06,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-ENV-07,ENV,error,Failed to open existing .env file for reading; aborting delete to avoid losing other variables,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||||
|
E-ENV-08,ENV,error,Failed to open .env file for writing,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||||
|
E-ENV-09,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-INF-01,INF,error,Error %d at %s:%d,Internal error - send the log to the firmware team
|
||||||
|
E-INF-02,INF,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
|
||||||
|
E-INF-03,INF,error,"Failed to perform HTTP request: %d, %s",Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-04,INF,error,"Bad request, check line protocol format: %s",Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-05,INF,error,Failed to read INFLUX_URL from .env file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-06,INF,error,Failed to read INFLUX_TOKEN from .env file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-07,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-INF-08,INF,error,Failed to open buffer file for reading,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-09,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-INF-10,INF,error,Failed to open buffer file for clearing,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-11,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-INF-12,INF,error,Failed to create buffer file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-13,INF,error,Failed to open buffer file for appending,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-14,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-INF-15,INF,error,Failed to open buffer file for counting,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-16,INF,error,Failed to read buffer for flushing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-17,INF,error,Failed to write chunk to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-18,INF,error,Failed to write chunk with 5 retries to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-19,INF,error,Failed to clear buffer after flushing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-20,INF,error,Failed to flush buffer before writing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-21,INF,error,Failed to write to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-INF-22,INF,error,Failed to append new entry to buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-OTA-01,OTA,error,Error %s at %s:%d,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-02,OTA,error,Failed to create OTA task,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-03,OTA,error,Failed to open NVS: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-04,OTA,error,Failed to read OTA token from NVS: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-05,OTA,error,Failed to read OTA token from NVS: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-06,OTA,error,Failed to get running partition,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-07,OTA,error,Failed to get OTA state,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-08,OTA,error,Failed to read update metadata from NVS: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-09,OTA,error,Failed to report failed selfcheck: %s,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-10,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-11,OTA,error,Device registration failed with status code: %d,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-12,OTA,error,Device registration failed: %s,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-13,OTA,error,Failed to check for updates: %s,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-14,OTA,error,Free stack size decreased from %u to %u,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-15,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-16,OTA,error,OTA Update check failed with status code: %d,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-17,OTA,error,"Update %s has already been tried %u times and failed, not trying again",Internal error - send the log to the firmware team
|
||||||
|
E-OTA-18,OTA,error,OTA update failed: %s,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-19,OTA,error,Failed to read update metadata from NVS: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-20,OTA,error,Failed to read update ID from NVS: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-21,OTA,error,Failed to read update ID from NVS: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-22,OTA,error,Failed to read update tries from NVS: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-23,OTA,error,Failing selfcheck: %s,Firmware update failed its self check and will roll back; check the sensors listed in the log
|
||||||
|
E-OTA-24,OTA,error,Failed to send update result: %s,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-25,OTA,error,Failed to mark app as invalid for rollback: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-26,OTA,error,Failed to send update result: %s,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-OTA-27,OTA,error,Failed to mark app as valid to commit update: %s,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-28,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
|
||||||
|
E-OTA-29,OTA,error,Failed to send update result with status code: %d,Check the network connection; if it persists send the log to the firmware team
|
||||||
|
E-WIFI-01,WIFI,error,esp_netif_init() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-02,WIFI,error,esp_event_loop_create_default() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-03,WIFI,error,esp_wifi_init() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-04,WIFI,error,Failed to register WIFI_EVENT handler: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-05,WIFI,error,Failed to register IP_EVENT handler: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-06,WIFI,error,esp_wifi_set_mode() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-07,WIFI,error,esp_timer_create() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-08,WIFI,error,esp_wifi_start() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-09,WIFI,error,esp_wifi_set_config() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-10,WIFI,error,esp_wifi_connect() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-11,WIFI,error,esp_wifi_set_mode(APSTA) failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-12,WIFI,error,esp_wifi_set_config(AP) failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-13,WIFI,error,esp_wifi_connect() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-14,WIFI,error,esp_wifi_scan_start() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-15,WIFI,error,esp_wifi_scan_get_ap_num() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-WIFI-16,WIFI,error,esp_wifi_scan_get_ap_records() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-PROV-01,PROV,error,mdns_init() failed: %s,Retry WiFi provisioning (short button press); restart the device if it persists
|
||||||
|
E-PROV-02,PROV,error,httpd_start() failed: %s,Retry WiFi provisioning (short button press); restart the device if it persists
|
||||||
|
E-MAIN-01,MAIN,error,Error %d at %s:%d,Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-02,MAIN,error,"Failed to open commissioning window, err:%",Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-03,MAIN,error,"Attribute update callback: endpoint_id: %u, cluster_id: 0x%lx, attribute_id: 0x%lx",Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-04,MAIN,error,Device MAC address: %02X:%02X:%02X:%02X:%02X:%02X,Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-05,MAIN,error,Failed to initialize SD card: %s,Reseat the SD card; if it persists replace the card or check the SD connector
|
||||||
|
E-MAIN-06,MAIN,error,Failed to initialize EnvironmentService: %s,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||||
|
E-MAIN-07,MAIN,error,Failed to initialize WifiCredentialsService: %s,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||||
|
E-MAIN-08,MAIN,error,WiFi provisioning server failed to start: %s; clearing HTTP_SERVER and restarting,Retry WiFi provisioning (short button press); restart the device if it persists
|
||||||
|
E-MAIN-09,MAIN,error,Failed to create Matter node,Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-10,MAIN,error,Failed to start ESP-Matter,Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-11,MAIN,error,Failed to start WifiService: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||||
|
E-MAIN-12,MAIN,error,"Failed to initialized the encrypted OTA, err: %d",Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-13,MAIN,error,Failed to initialize RTC: %s,Check the RTC (DS1307) and the sensor cable
|
||||||
|
E-MAIN-14,MAIN,error,Failed to initialize Influx buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-MAIN-15,MAIN,error,Failed to initialize BME688: %s,Check the BME688 sensor and the sensor cable
|
||||||
|
E-MAIN-16,MAIN,error,Failed to initialize SEN66: %s,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-MAIN-17,MAIN,error,Failed to initialize AS7341: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-MAIN-18,MAIN,error,Failed to initialize Microphone: %s,Check the microphone board and its cable
|
||||||
|
E-MAIN-19,MAIN,error,Failed to initialize Flicker sensor: %s,Check the flicker sensor and its cable
|
||||||
|
E-MAIN-20,MAIN,error,Failed to initialize DeviceStatus: %s,Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-21,MAIN,error,BME688 self check failed: %s,Check the BME688 sensor and the sensor cable
|
||||||
|
E-MAIN-22,MAIN,error,SEN66 self check failed: %s,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-MAIN-23,MAIN,error,AS7341 self check failed: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-MAIN-24,MAIN,error,Microphone self check failed: %s,Check the microphone board and its cable
|
||||||
|
E-MAIN-25,MAIN,error,Flicker sensor self check failed: %s,Check the flicker sensor and its cable
|
||||||
|
E-MAIN-26,MAIN,error,Failed to report passed selfcheck: %s,Internal error - send the log to the firmware team
|
||||||
|
E-MAIN-27,MAIN,error,Failed to flush Influx buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||||
|
E-STAT-01,STAT,error,Failed to initialize LED strip: %s,Internal error - send the log to the firmware team
|
||||||
|
E-SPI-01,SPI,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||||
|
E-RTC-01,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-RTC-02,RTC,error,Failed to add I2C device: %s,Check the RTC (DS1307) and the sensor cable
|
||||||
|
E-RTC-03,RTC,error,Failed to initialize DS1307 control register: %s,Check the RTC (DS1307) and the sensor cable
|
||||||
|
E-RTC-04,RTC,error,Attempted to set time on uninitialized DS1307,Internal error - send the log to the firmware team
|
||||||
|
E-RTC-05,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-RTC-06,RTC,error,Attempted to get time on uninitialized DS1307,Internal error - send the log to the firmware team
|
||||||
|
E-RTC-07,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-RTC-08,RTC,error,Attempted to start updating time on uninitialized DS1307,Internal error - send the log to the firmware team
|
||||||
|
E-BME-01,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-BME-02,BME,error,Failed to add I2C device: %d,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-03,BME,error,Failed to initialize BME688: %d,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-04,BME,error,Failed to set BME688 configuration: %d,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-05,BME,error,Failed to set BME688 heater configuration: %d,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-06,BME,error,Failed to set BME688 operation mode: %d,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-07,BME,error,Failed to get BME688 data: %d,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-08,BME,error,No new data available from BME688,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-09,BME,error,Failed to get data from BME688: %s,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-10,BME,error,Self check failed: %s,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-11,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-BME-12,BME,error,I2C read failed: %s,Check the BME688 sensor and the sensor cable
|
||||||
|
E-BME-13,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-BME-14,BME,error,Failed to allocate memory for I2C write buffer,Internal error - send the log to the firmware team
|
||||||
|
E-BME-15,BME,error,I2C write failed: %s,Check the BME688 sensor and the sensor cable
|
||||||
|
E-SEN-01,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-SEN-02,SEN,error,Failed to add I2C device: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-03,SEN,error,Attempted to wake up uninitialized SEN66,Internal error - send the log to the firmware team
|
||||||
|
E-SEN-04,SEN,error,Failed to take sensor mutex,Internal error - send the log to the firmware team
|
||||||
|
E-SEN-05,SEN,error,Failed to send start measurement command: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-06,SEN,error,Attempted to sleep uninitialized SEN66,Internal error - send the log to the firmware team
|
||||||
|
E-SEN-07,SEN,error,Failed to take sensor mutex,Internal error - send the log to the firmware team
|
||||||
|
E-SEN-08,SEN,error,Failed to send stop measurement command: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-09,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-SEN-10,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-SEN-11,SEN,error,Failed to read ready status: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-12,SEN,error,"Sensor not ready after %d attempts, trying to wake it up","Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-13,SEN,error,Failed to read measurement: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-14,SEN,error,Failed while awaiting sensor ready: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-15,SEN,error,Failed to read measurement for line protocol: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-16,SEN,error,Self-check failed: Sensor not ready: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-SEN-17,SEN,error,Self-check failed: Failed to read measurement: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||||
|
E-AS-01,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-AS-02,AS,error,Failed to initialize AS7341: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-AS-03,AS,error,Attempted to wake up uninitialized AS7341,Internal error - send the log to the firmware team
|
||||||
|
E-AS-04,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-AS-05,AS,error,Failed to wake up AS7341: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-AS-06,AS,error,Attempted to put uninitialized AS7341 to sleep,Internal error - send the log to the firmware team
|
||||||
|
E-AS-07,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-AS-08,AS,error,Failed to put AS7341 to sleep: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-AS-09,AS,error,Attempted to get line protocol on uninitialized AS7341,Internal error - send the log to the firmware team
|
||||||
|
E-AS-10,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-AS-11,AS,error,Failed to read spectral data: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-AS-12,AS,error,Failed to read basic counts data: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-AS-13,AS,error,Failed to read flicker detection state: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-AS-14,AS,error,Self-check failed: Failed to read spectral data: %s,Check the AS7341 light sensor and the sensor cable
|
||||||
|
E-MIC-01,MIC,error,Failed to create I2S channel: %s,Check the microphone board and its cable
|
||||||
|
E-MIC-02,MIC,error,Failed to initialize I2S channel in standard mode: %s,Check the microphone board and its cable
|
||||||
|
E-MIC-03,MIC,error,Failed to configure shutdown pin: %s,Check the microphone board and its cable
|
||||||
|
E-MIC-04,MIC,error,Failed to read samples from I2S channel: %s,Check the microphone board and its cable
|
||||||
|
E-MIC-05,MIC,error,Failed to read samples for line protocol: %s,Check the microphone board and its cable
|
||||||
|
E-FLK-01,FLK,error,Error %s at %s:%d,Internal error - send the log to the firmware team
|
||||||
|
E-FLK-02,FLK,error,Attempting to initialize sensor that is already initialized,Check the flicker sensor and its cable
|
||||||
|
E-FLK-03,FLK,error,Failed to allocate memory for FFT buffers,Internal error - send the log to the firmware team
|
||||||
|
E-FLK-04,FLK,error,Failed to initialize FFT: %s,Check the flicker sensor and its cable
|
||||||
|
E-FLK-05,FLK,error,Attempting to read from uninitialized sensor,Internal error - send the log to the firmware team
|
||||||
|
E-FLK-06,FLK,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||||
|
E-FLK-07,FLK,error,Failed to get dominant frequency: %s,Check the flicker sensor and its cable
|
||||||
|
E-FLK-08,FLK,error,Attempting to read from uninitialized sensor,Internal error - send the log to the firmware team
|
||||||
|
E-FLK-09,FLK,error,Failed to read ADC value: %s,Check the flicker sensor and its cable
|
||||||
|
E-FLK-10,FLK,error,Failed to create gather task: %s,Internal error - send the log to the firmware team
|
||||||
|
E-FLK-11,FLK,error,Attempting to self check uninitialized sensor,Internal error - send the log to the firmware team
|
||||||
|
E-FLK-12,FLK,error,Self check failed to get dominant frequency: %s,Check the flicker sensor and its cable
|
||||||
|
E-DEV-01,DEV,error,Failed to initialize ADC unit: %s,Internal error - send the log to the firmware team
|
||||||
|
E-DEV-02,DEV,error,Failed to configure ADC channel: %s,Internal error - send the log to the firmware team
|
||||||
|
E-DEV-03,DEV,error,Failed to install temperature sensor: %s,Internal error - send the log to the firmware team
|
||||||
|
E-DEV-04,DEV,error,Failed to read ADC value: %s,Internal error - send the log to the firmware team
|
||||||
|
E-DEV-05,DEV,error,Failed to allocate memory for task list buffer,Internal error - send the log to the firmware team
|
||||||
|
E-GATH-01,GATH,error,Failed to take line protocol mutex,Internal error - send the log to the firmware team
|
||||||
|
E-GATH-02,GATH,error,Failed to get line protocol from sensor,Device ran out of memory; restart it and send the log to the firmware team
|
||||||
|
E-GATH-03,GATH,error,Failed to create task for sensor,Internal error - send the log to the firmware team
|
||||||
|
E-GATH-04,GATH,error,Failed to create main gather task,Internal error - send the log to the firmware team
|
||||||
|
+147
-91
@@ -1,91 +1,147 @@
|
|||||||
"""Firmware identification from the boot log.
|
"""Firmware identification from the boot log.
|
||||||
|
|
||||||
The app version string is "1.0" in every build seen so far, so builds are
|
Up to firmware 1.0 the app version string was "1.0" in every build, so
|
||||||
told apart by the ELF SHA256 prefix printed by app_init. Boxes receive new
|
builds are told apart by the ELF SHA256 prefix printed by app_init. Boxes
|
||||||
firmware over OTA; an unknown SHA usually means a box has already updated.
|
receive new firmware over OTA; an unknown SHA usually means a box has already
|
||||||
"""
|
updated.
|
||||||
|
|
||||||
from __future__ import annotations
|
Firmware 1.0.16+ prints "KOTA: boot fw=1.0.16 build=4f10989e ..." (version
|
||||||
|
unique per build, first 8 hex digits of the ELF SHA256). Builds found in the
|
||||||
import re
|
tool's firmware/ folder are registered with `register_local_build` so the
|
||||||
from dataclasses import dataclass, field
|
box's build can be shown with its compile date.
|
||||||
|
"""
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
from __future__ import annotations
|
||||||
class KnownFirmware:
|
|
||||||
name: str
|
import re
|
||||||
source: str
|
from dataclasses import dataclass, field
|
||||||
sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT
|
|
||||||
setup_portal: bool # short press -> Wi-Fi setup portal
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KnownFirmware:
|
||||||
KNOWN: dict[str, KnownFirmware] = {
|
name: str
|
||||||
"45fa9d5b9": KnownFirmware(
|
source: str
|
||||||
name="v1.0 (built Jun 29 2026)",
|
sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT
|
||||||
source="factory image new_system.bin",
|
setup_portal: bool # short press -> Wi-Fi setup portal
|
||||||
sd_wifi=False,
|
|
||||||
setup_portal=False,
|
|
||||||
),
|
KNOWN: dict[str, KnownFirmware] = {
|
||||||
}
|
"45fa9d5b9": KnownFirmware(
|
||||||
|
name="v1.0 (built Jun 29 2026)",
|
||||||
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
|
source="factory image new_system.bin",
|
||||||
_VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)")
|
sd_wifi=False,
|
||||||
_BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$")
|
setup_portal=False,
|
||||||
_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)")
|
),
|
||||||
# Evidence of features, for builds not in KNOWN.
|
"c94ba0e9b": KnownFirmware(
|
||||||
_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ")
|
name="v1.0 source build (Sep 26 2026)",
|
||||||
_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ")
|
source="developer build, built by Krzysztof Kallas",
|
||||||
# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag.
|
sd_wifi=True,
|
||||||
_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted")
|
setup_portal=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
@dataclass
|
|
||||||
class FirmwareInfo:
|
# elf sha (full or prefix) -> (version, compile time, file name) of builds in firmware/
|
||||||
elf_sha: str | None = None
|
LOCAL_BUILDS: dict[str, tuple[str, str, str]] = {}
|
||||||
version: str | None = None
|
|
||||||
built: str | None = None
|
|
||||||
idf: str | None = None
|
def register_local_build(elf_sha: str, version: str, built: str, file_name: str) -> None:
|
||||||
evidence: set[str] = field(default_factory=set)
|
LOCAL_BUILDS[elf_sha.lower()] = (version, built, file_name)
|
||||||
|
|
||||||
def observe(self, text: str) -> None:
|
|
||||||
if m := _ELF.search(text):
|
def same_build(a: str | None, b: str | None) -> bool:
|
||||||
self.elf_sha = m.group("sha")
|
"""ELF SHA prefixes of different lengths (8 from KOTA: boot, 9+ from
|
||||||
elif m := _VER.search(text):
|
app_init, 64 from an image) name the same build if they agree."""
|
||||||
self.version = m.group("v")
|
if not a or not b:
|
||||||
elif m := _BUILT.search(text):
|
return False
|
||||||
self.built = m.group("t").strip()
|
n = min(len(a), len(b))
|
||||||
elif m := _IDF.search(text):
|
return n >= 6 and a[:n].lower() == b[:n].lower()
|
||||||
self.idf = m.group("v")
|
|
||||||
if _FEATURE_SD_WIFI.search(text):
|
|
||||||
self.evidence.add("sd_wifi")
|
def local_build(elf_sha: str | None) -> tuple[str, str, str] | None:
|
||||||
if _FEATURE_PORTAL.search(text):
|
return next((v for sha, v in LOCAL_BUILDS.items() if same_build(sha, elf_sha)), None)
|
||||||
self.evidence.add("setup_portal")
|
|
||||||
if _LEGACY_SD_TAG.search(text):
|
|
||||||
self.evidence.add("legacy_v1")
|
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
|
||||||
|
_VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)")
|
||||||
@property
|
_BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$")
|
||||||
def known(self) -> KnownFirmware | None:
|
_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)")
|
||||||
if not self.elf_sha:
|
# Evidence of features, for builds not in KNOWN.
|
||||||
return None
|
_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ")
|
||||||
return next((k for sha, k in KNOWN.items() if self.elf_sha.startswith(sha)), None)
|
_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ")
|
||||||
|
# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag.
|
||||||
@property
|
_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted")
|
||||||
def supports_sd_wifi(self) -> bool | None:
|
|
||||||
"""True/False when determinable, None when unknown (no boot seen)."""
|
|
||||||
if k := self.known:
|
@dataclass
|
||||||
return k.sd_wifi
|
class FirmwareInfo:
|
||||||
if "sd_wifi" in self.evidence:
|
elf_sha: str | None = None
|
||||||
return True
|
version: str | None = None
|
||||||
if "legacy_v1" in self.evidence:
|
built: str | None = None
|
||||||
return False
|
idf: str | None = None
|
||||||
return None
|
hw: str | None = None
|
||||||
|
evidence: set[str] = field(default_factory=set)
|
||||||
def describe(self) -> str:
|
|
||||||
if k := self.known:
|
def observe_kota(self, fields: dict[str, str]) -> None:
|
||||||
return f"{k.name} - {k.source}"
|
"""'KOTA: boot fw=1.0.16 build=4f10989e hw=2 ...'"""
|
||||||
if self.elf_sha:
|
if fields.get("fw"):
|
||||||
built = f", built {self.built}" if self.built else ""
|
self.version = fields["fw"]
|
||||||
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
|
build = fields.get("build", "")
|
||||||
if "legacy_v1" in self.evidence:
|
# app_init's longer prefix is kept if it agrees.
|
||||||
return "Probably v1.0 factory image (press Reset & capture to confirm)"
|
if build and not same_build(self.elf_sha, build):
|
||||||
return "Not identified yet - press Reset & capture"
|
self.elf_sha = build.lower()
|
||||||
|
if fields.get("hw"):
|
||||||
|
self.hw = fields["hw"]
|
||||||
|
self.evidence.add("kota") # 1.0.16+: SD Wi-Fi and setup portal exist
|
||||||
|
|
||||||
|
def observe(self, text: str) -> None:
|
||||||
|
if m := _ELF.search(text):
|
||||||
|
self.elf_sha = m.group("sha")
|
||||||
|
elif m := _VER.search(text):
|
||||||
|
self.version = m.group("v")
|
||||||
|
elif m := _BUILT.search(text):
|
||||||
|
self.built = m.group("t").strip()
|
||||||
|
elif m := _IDF.search(text):
|
||||||
|
self.idf = m.group("v")
|
||||||
|
if _FEATURE_SD_WIFI.search(text):
|
||||||
|
self.evidence.add("sd_wifi")
|
||||||
|
if _FEATURE_PORTAL.search(text):
|
||||||
|
self.evidence.add("setup_portal")
|
||||||
|
if _LEGACY_SD_TAG.search(text):
|
||||||
|
self.evidence.add("legacy_v1")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def known(self) -> KnownFirmware | None:
|
||||||
|
if not self.elf_sha:
|
||||||
|
return None
|
||||||
|
return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_sd_wifi(self) -> bool | None:
|
||||||
|
"""True/False when determinable, None when unknown (no boot seen)."""
|
||||||
|
if k := self.known:
|
||||||
|
return k.sd_wifi
|
||||||
|
if "sd_wifi" in self.evidence or "kota" in self.evidence:
|
||||||
|
return True
|
||||||
|
if "legacy_v1" in self.evidence:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
if k := self.known:
|
||||||
|
return f"{k.name} - {k.source}"
|
||||||
|
if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)):
|
||||||
|
local = local_build(self.elf_sha)
|
||||||
|
built = self.built or (local[1] if local else None)
|
||||||
|
text = f"v{self.version or (local[0] if local else '?')} (build {self.elf_sha[:8]}"
|
||||||
|
text += f", compiled {built})" if built else ")"
|
||||||
|
if self.hw:
|
||||||
|
text += f", hardware {self.hw}"
|
||||||
|
if local:
|
||||||
|
text += f" - same as firmware/{local[2]}"
|
||||||
|
return text
|
||||||
|
if self.elf_sha:
|
||||||
|
built = f", built {self.built}" if self.built else ""
|
||||||
|
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
|
||||||
|
if "legacy_v1" in self.evidence:
|
||||||
|
return "Probably v1.0 factory image (press Reset & capture to confirm)"
|
||||||
|
return "Not identified yet - press Reset & capture"
|
||||||
@@ -21,7 +21,7 @@ from .engine import LED, DiagnosticsEngine, Severity
|
|||||||
|
|
||||||
GUIDES_PATH = Path(__file__).with_name("guides.toml")
|
GUIDES_PATH = Path(__file__).with_name("guides.toml")
|
||||||
# LED answers a technician can give (engine colours + a reboot-loop pattern).
|
# LED answers a technician can give (engine colours + a reboot-loop pattern).
|
||||||
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "OFF", "CYCLING"]
|
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "CYAN", "OFF", "CYCLING"]
|
||||||
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
|
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
|
||||||
"CYCLING": "Keeps changing / goes dark every few seconds"}
|
"CYCLING": "Keeps changing / goes dark every few seconds"}
|
||||||
STEP_TYPES = {"led", "question", "info", "wait", "route"}
|
STEP_TYPES = {"led", "question", "info", "wait", "route"}
|
||||||
@@ -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", [])}
|
||||||
|
|||||||
+740
-715
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,263 @@
|
|||||||
|
"""Knowledge about the structured "KOTA:" lines of firmware 1.0.16+.
|
||||||
|
|
||||||
|
Firmware source of truth (kota-sensorboard/main):
|
||||||
|
boot_info.cpp "boot fw=.. build=.. hw=.. mac=.. reset=.. mode=.. commissioned=.. last_restart=.."
|
||||||
|
"restart reason=.." (W, printed just before a self-restart)
|
||||||
|
status.cpp "status A -> B [code=N] reason=x", "led color=.. pattern=.. [n=..|period=..ms]",
|
||||||
|
"led previous_boot status=.. code=..", "led restart_flash color=.."
|
||||||
|
i2c.cpp "i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307(missing) .. [bus=stuck]"
|
||||||
|
ds1307.cpp "rtc running=1 time=..", "rtc read_failed err=..", "rtc verify running=1 delta=2s ok=1"
|
||||||
|
sd_card.cpp "sd type=.. size=.. fs=.. free=.. env_keys=A,B buffer_lines=N", "sd mounted=0 [err=..]"
|
||||||
|
main.cpp "matter fabrics=N window=open|closed qr=MT:.. manual=.."
|
||||||
|
microphone.cpp "mic enabled=0 (GPIO3=1, expected 0)"
|
||||||
|
health.cpp "health up=60s status=OK wifi=connected rssi=-61 time_valid=1 sd=ok buffer=0
|
||||||
|
heap=123456 psram=7.8M last_upload=12s|never ota=registered|unregistered|selfcheck"
|
||||||
|
|
||||||
|
Error IDs ("[E-SD-03] ...") come from error_ids.csv, a copy of the
|
||||||
|
firmware's docs/error_ids.csv (the firmware build generates its IDs from it).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ERROR_IDS_PATH = Path(__file__).with_name("error_ids.csv")
|
||||||
|
|
||||||
|
# Firmware colour names (status.cpp colors[]) -> engine LED keys.
|
||||||
|
LED_COLOURS = {
|
||||||
|
"green": "GREEN", "cyan": "CYAN", "white": "WHITE", "red": "RED",
|
||||||
|
"blue": "BLUE", "purple": "PURPLE", "yellow": "YELLOW", "orange": "ORANGE",
|
||||||
|
}
|
||||||
|
|
||||||
|
RED_CODES = {1: "BME688", 2: "SEN66", 3: "AS7341", 4: "Microphone", 5: "Flicker sensor", 6: "Self-check"}
|
||||||
|
I2C_DEVICES = {"0x39": "AS7341", "0x68": "DS1307", "0x6B": "SEN66", "0x76": "BME688"}
|
||||||
|
|
||||||
|
# last_restart values written by kota_restart() that are part of normal
|
||||||
|
# operation (button presses, a finished setup, an installed update).
|
||||||
|
INTENTIONAL_RESTARTS = {
|
||||||
|
"ota_update", "button_provisioning_abort", "button_wifi_cleared",
|
||||||
|
"button_provisioning_toggle", "provisioning_saved",
|
||||||
|
}
|
||||||
|
|
||||||
|
MODULES = {
|
||||||
|
"SD": "SD card", "ENV": "ENV.TXT", "INF": "InfluxDB upload", "OTA": "Firmware update",
|
||||||
|
"WIFI": "Wi-Fi", "PROV": "Wi-Fi setup", "MAIN": "Start-up", "STAT": "Status LED", "SPI": "SPI bus",
|
||||||
|
"RTC": "Clock (RTC)", "BME": "BME688", "SEN": "SEN66", "AS": "AS7341", "MIC": "Microphone",
|
||||||
|
"FLK": "Flicker sensor", "DEV": "Device", "GATH": "Sensor readings",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ErrorId:
|
||||||
|
id: str
|
||||||
|
module: str
|
||||||
|
severity: str # error | warning | retired
|
||||||
|
message: str
|
||||||
|
action: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def module_name(self) -> str:
|
||||||
|
return MODULES.get(self.module, self.module)
|
||||||
|
|
||||||
|
|
||||||
|
def load_error_ids(path: Path = ERROR_IDS_PATH) -> dict[str, ErrorId]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
with path.open(encoding="utf-8", newline="") as fh:
|
||||||
|
return {r["id"]: ErrorId(r["id"], r["module"], r["severity"], r["message"], r["action"])
|
||||||
|
for r in csv.DictReader(fh)}
|
||||||
|
|
||||||
|
|
||||||
|
def led_for_status(status: str, code: int) -> tuple[str, str]:
|
||||||
|
"""(LED key, pattern text) for a status - mirrors Status::pattern_for().
|
||||||
|
Used when the box's own "led" line was not captured (connected mid-run)."""
|
||||||
|
if status == "OK":
|
||||||
|
return "GREEN", "solid"
|
||||||
|
if status == "ERROR":
|
||||||
|
return ("RED", f"{code} pulses") if 1 <= code <= 6 else ("RED", "solid")
|
||||||
|
if status == "OUT_OF_RESOURCES":
|
||||||
|
return "RED", "solid"
|
||||||
|
if status == "UPDATING":
|
||||||
|
return "YELLOW", "blinking"
|
||||||
|
if status == "SELF_CHECK":
|
||||||
|
return "ORANGE", "solid"
|
||||||
|
if status == "CONNECTION_ERROR":
|
||||||
|
return ("PURPLE", "blinking") if code == 1 else ("PURPLE", "solid")
|
||||||
|
if status == "RTC_ERROR":
|
||||||
|
return "BLUE", "2 pulses" if code == 2 else "1 pulse"
|
||||||
|
if status == "SD_ERROR":
|
||||||
|
return "WHITE", "fast blinking"
|
||||||
|
if status == "RESETING":
|
||||||
|
return "WHITE", "solid"
|
||||||
|
if status == "HTTP_SERVER":
|
||||||
|
return "WHITE", "breathing"
|
||||||
|
return "UNKNOWN", ""
|
||||||
|
|
||||||
|
|
||||||
|
def pattern_text(fields: dict[str, str]) -> str:
|
||||||
|
"""'led color=red pattern=pulses n=2' -> '2 pulses'."""
|
||||||
|
p = fields.get("pattern", "")
|
||||||
|
if p == "pulses":
|
||||||
|
n = fields.get("n", "?")
|
||||||
|
return f"{n} pulse" if n == "1" else f"{n} pulses"
|
||||||
|
if p == "blink":
|
||||||
|
period = fields.get("period", "").removesuffix("ms")
|
||||||
|
fast = period.isdigit() and int(period) <= 500
|
||||||
|
return ("fast blinking" if fast else "blinking") + (f" ({period} ms)" if period else "")
|
||||||
|
if p == "breathe":
|
||||||
|
return "breathing"
|
||||||
|
return p # solid / off
|
||||||
|
|
||||||
|
|
||||||
|
def led_meaning(colour: str, pattern: str) -> str:
|
||||||
|
"""Technician meaning of a colour + pattern (firmware diagnostics doc)."""
|
||||||
|
if colour == "GREEN":
|
||||||
|
return "OK"
|
||||||
|
if colour == "CYAN":
|
||||||
|
return "phone connected for pairing" if "fast" in pattern else "waiting to be paired in Matter"
|
||||||
|
if colour == "WHITE":
|
||||||
|
if "fast" in pattern:
|
||||||
|
return "SD card or ENV.TXT error"
|
||||||
|
if "breath" in pattern:
|
||||||
|
return "Wi-Fi setup portal"
|
||||||
|
return "factory reset"
|
||||||
|
if colour == "RED":
|
||||||
|
if "pulse" in pattern:
|
||||||
|
n = int(pattern.split()[0]) if pattern.split()[0].isdigit() else 0
|
||||||
|
return f"{RED_CODES.get(n, 'sensor')} failed"
|
||||||
|
return "internal error / out of memory"
|
||||||
|
if colour == "BLUE":
|
||||||
|
return "I2C bus stuck (sensor cable)" if pattern.startswith("2") else "clock (RTC) not responding"
|
||||||
|
if colour == "PURPLE":
|
||||||
|
return "server rejected the box (check ENV.TXT credentials)" if "blink" in pattern else "no network"
|
||||||
|
if colour == "YELLOW":
|
||||||
|
return "downloading a firmware update"
|
||||||
|
if colour == "ORANGE":
|
||||||
|
return "self-check after an update"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# Finding for the current firmware status (Status::set_status). key suffix ->
|
||||||
|
# (severity, title, meaning, actions, explained_by). The status finding is
|
||||||
|
# grouped under a more specific finding when one explains it.
|
||||||
|
_SENSOR_ROOTS = ("sensor.init_failed", "sensor.selfcheck_failed", "kota.i2c_stuck", "kota.sensor_missing",
|
||||||
|
"sen66.not_ready", "bme688.read_fail", "as7341.read_fail", "mic.read_fail",
|
||||||
|
"ota.selfcheck_failed")
|
||||||
|
_NET_ROOTS = ("matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal", "kota.wifi_down",
|
||||||
|
"net.dns_fail", "net.tls_fail")
|
||||||
|
|
||||||
|
|
||||||
|
def status_finding(status: str, code: int) -> tuple[str, str, str, str, tuple[str, ...], tuple[str, ...]] | None:
|
||||||
|
"""(key, severity, title, meaning, actions, explained_by) or None for healthy states."""
|
||||||
|
if status == "ERROR":
|
||||||
|
if 1 <= code <= 5:
|
||||||
|
s = RED_CODES[code]
|
||||||
|
return (f"status.error.{code}", "critical", f"{s} fault (LED red, {code} pulses)",
|
||||||
|
f"The firmware reports the {s} as failed.",
|
||||||
|
(f"Open the case and check the {s} connector/cable.",
|
||||||
|
"If several sensors fail: check the shared I2C cable.",
|
||||||
|
"The box restarts itself after 150 s in this state."), _SENSOR_ROOTS)
|
||||||
|
if code == 6:
|
||||||
|
return ("status.error.6", "error", "Post-update self-check failed (LED red, 6 pulses)",
|
||||||
|
"The new firmware failed its self-check and rolls back.",
|
||||||
|
("Check the sensor named in the log (inside the case), then power-cycle.",), _SENSOR_ROOTS)
|
||||||
|
return ("status.error", "error", "Internal error (LED red)",
|
||||||
|
"The firmware is in its general error state.",
|
||||||
|
("Save the session log and send it to the firmware team.",
|
||||||
|
"The box restarts itself after 150 s in this state."), _SENSOR_ROOTS)
|
||||||
|
if status == "OUT_OF_RESOURCES":
|
||||||
|
return ("status.oom", "error", "Firmware ran out of memory (LED red)",
|
||||||
|
"The firmware could not allocate memory; it restarts itself after 30 s.",
|
||||||
|
("Power-cycle. If it repeats, save the session log for the firmware team.",),
|
||||||
|
("sys.out_of_resources",))
|
||||||
|
if status == "CONNECTION_ERROR":
|
||||||
|
if code == 1:
|
||||||
|
return ("status.conn.1", "critical", "Server rejected the box (LED purple, blinking)",
|
||||||
|
"The network works but the server refused the box (HTTP 401/403/404): "
|
||||||
|
"INFLUX_URL / INFLUX_TOKEN in ENV.TXT are wrong or revoked.",
|
||||||
|
("Re-provision the SD card with the current ENV.TXT (SD card is inside the case).",),
|
||||||
|
("influx.auth", "influx.not_found"))
|
||||||
|
return ("status.conn", "error", "No connection to the servers (LED purple)",
|
||||||
|
"Uploads or the update check fail: no Wi-Fi, no Internet or the server is unreachable.",
|
||||||
|
("Check the box is paired/connected to Wi-Fi and the network has Internet.",),
|
||||||
|
_NET_ROOTS + ("influx.buffer_fail", "ota.check_failed", "influx.server_error"))
|
||||||
|
if status == "RTC_ERROR":
|
||||||
|
if code == 2:
|
||||||
|
return ("status.rtc.2", "critical", "I2C bus stuck (LED blue, 2 pulses)",
|
||||||
|
"SDA or SCL is held low: a shorted or broken sensor cable, or a faulty sensor.",
|
||||||
|
("Open the case and check the I2C cable between the boards.",
|
||||||
|
"Unplug sensors one by one to find the one holding the bus."), ("kota.i2c_stuck",))
|
||||||
|
return ("status.rtc.1", "critical", "Clock chip (RTC) not responding (LED blue, 1 pulse)",
|
||||||
|
"The DS1307 clock does not answer on the I2C bus.",
|
||||||
|
("Open the case and check the I2C cable/connector between the boards.",
|
||||||
|
"If the cable is fine: the RTC module is faulty."),
|
||||||
|
("rtc.init_failed", "kota.rtc_read_failed", "kota.sensor_missing"))
|
||||||
|
if status == "SD_ERROR":
|
||||||
|
return ("status.sd", "critical", "SD card or ENV.TXT error (LED white, fast blinking)",
|
||||||
|
"The SD card cannot be read or ENV.TXT is missing settings.",
|
||||||
|
("Open the case, re-seat the SD card or re-provision it with the tool.",),
|
||||||
|
("kota.sd_unmounted", "influx.env_missing", "sd.card_fail", "sd.mount_fail",
|
||||||
|
"sd.env_open_fail", "influx.init_fail"))
|
||||||
|
if status == "RESETING":
|
||||||
|
return ("status.reset", "warning", "Factory reset in progress (LED white, solid)",
|
||||||
|
"The Matter button was held: the box forgets its Matter network.",
|
||||||
|
("Pair the box again in the Matter app afterwards.",), ("sys.factory_reset",))
|
||||||
|
if status == "HTTP_SERVER":
|
||||||
|
return ("status.portal", "warning", "Box is in Wi-Fi setup mode (LED white, breathing)",
|
||||||
|
"The box hosts its own Wi-Fi network for configuration.",
|
||||||
|
("Connect a phone to the box's network and open http://sensorbox.local/,",
|
||||||
|
"or short-press the Matter button to leave setup mode."), ("wifi.setup_portal",))
|
||||||
|
return None # OK, UPDATING, SELF_CHECK: covered by milestones / OTA rules
|
||||||
|
|
||||||
|
|
||||||
|
# last_restart -> (severity, title, meaning, actions); None = not a fault.
|
||||||
|
def restart_finding(reason: str) -> tuple[str, str, str, tuple[str, ...]] | None:
|
||||||
|
if reason in INTENTIONAL_RESTARTS or reason in ("none", "", "-"):
|
||||||
|
return None
|
||||||
|
if reason == "status_watchdog":
|
||||||
|
return ("warning", "Box restarted itself after 150 s in the red error state",
|
||||||
|
"By design the firmware restarts when it stays in the error state (red) for 150 s.",
|
||||||
|
("Fix the grouped cause; the restarts stop by themselves.",))
|
||||||
|
if reason == "status_oom_watchdog":
|
||||||
|
return ("error", "Box restarted itself because it ran out of memory",
|
||||||
|
"The firmware was out of memory for 30 s and restarted.",
|
||||||
|
("Save the session log for the firmware team.",))
|
||||||
|
if reason == "provisioning_failed":
|
||||||
|
return ("warning", "Wi-Fi setup portal failed to start",
|
||||||
|
"The setup portal could not start; the box cleared setup mode and restarted.",
|
||||||
|
("Retry Wi-Fi setup (short button press); restart the device if it persists.",))
|
||||||
|
if reason == "panic":
|
||||||
|
return ("critical", "Firmware crashed (panic) and restarted",
|
||||||
|
"The firmware hit a fatal error.",
|
||||||
|
("Press Reset & capture to record the crash; save the session log for the firmware team.",))
|
||||||
|
if reason in ("task_wdt", "int_wdt", "wdt"):
|
||||||
|
return ("error", "Firmware froze and was restarted by the watchdog",
|
||||||
|
f"The firmware stopped responding ({reason}); the watchdog restarted it.",
|
||||||
|
("Save the session log for the firmware team.",))
|
||||||
|
if reason == "brownout":
|
||||||
|
return ("error", "Power supply voltage dropped (brown-out)",
|
||||||
|
"The supply voltage fell too low and the chip reset itself.",
|
||||||
|
("Check the power supply, cable and connectors.",))
|
||||||
|
if reason == "unknown_sw":
|
||||||
|
return ("warning", "Unexpected software restart",
|
||||||
|
"The firmware restarted without recording a reason.",
|
||||||
|
("Watch whether it repeats; save the session log for the firmware team.",))
|
||||||
|
return ("warning", f"Box restarted itself ({reason})",
|
||||||
|
f"The firmware recorded the restart reason '{reason}'.",
|
||||||
|
("Save the session log for the firmware team if it repeats.",))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_gb(value: str) -> float | None:
|
||||||
|
"""'7.3GB' -> 7.3 (sd_card.cpp format_gb)."""
|
||||||
|
v = value.upper().removesuffix("GB")
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_seconds(value: str) -> int | None:
|
||||||
|
v = value.removesuffix("s")
|
||||||
|
return int(v) if v.lstrip("-").isdigit() else None
|
||||||
+164
-4
@@ -8,6 +8,9 @@
|
|||||||
# Fields:
|
# Fields:
|
||||||
# id unique, "<category>.<name>"
|
# id unique, "<category>.<name>"
|
||||||
# pattern Python regex (search). Named groups can be used in `title`.
|
# pattern Python regex (search). Named groups can be used in `title`.
|
||||||
|
# Matched against the line with its error ID ("[E-SD-03] ")
|
||||||
|
# removed. Rules without a pattern ("kota.*") are raised by
|
||||||
|
# the engine from the structured KOTA: lines (diag/kota.py).
|
||||||
# severity critical | error | warning | info | ignore
|
# severity critical | error | warning | info | ignore
|
||||||
# ignore = never shown (benign ESP_LOGE lines, LED-only rules)
|
# ignore = never shown (benign ESP_LOGE lines, LED-only rules)
|
||||||
# title short technician-facing headline
|
# title short technician-facing headline
|
||||||
@@ -121,7 +124,7 @@ led_from = "PURPLE"
|
|||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
id = "benign.mac"
|
id = "benign.mac"
|
||||||
pattern = 'ID: Device MAC address'
|
pattern = 'Device MAC address'
|
||||||
severity = "ignore"
|
severity = "ignore"
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
@@ -134,6 +137,14 @@ id = "benign.zcl"
|
|||||||
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
|
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
|
||||||
severity = "ignore"
|
severity = "ignore"
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "benign.wifi_start"
|
||||||
|
# Harmless Wi-Fi noise seen on every 1.0.16 boot: Matter asks for a connect
|
||||||
|
# while the station is already connecting, and an unpaired box polls a
|
||||||
|
# network it does not have yet (matter.not_commissioned covers that case).
|
||||||
|
pattern = 'wifi:sta is connecting, return error|esp_wifi_connect\(\) failed: ESP_ERR_WIFI_CONN|Failed to get configured network when updating network status'
|
||||||
|
severity = "ignore"
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
id = "benign.connect_chain"
|
id = "benign.connect_chain"
|
||||||
# Transport-level echo of a failure that a more specific rule reports.
|
# Transport-level echo of a failure that a more specific rule reports.
|
||||||
@@ -222,7 +233,7 @@ id = "influx.buffer_fail"
|
|||||||
pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer'
|
pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer'
|
||||||
severity = "error"
|
severity = "error"
|
||||||
title = "Measurements cannot be uploaded to InfluxDB"
|
title = "Measurements cannot be uploaded to InfluxDB"
|
||||||
meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple and the box restarts itself after about 2.5 minutes in this state."
|
meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple (firmware v1.0 also restarts itself after about 2.5 minutes in this state)."
|
||||||
actions = [
|
actions = [
|
||||||
"Fix the network problem first (see the finding this is grouped under).",
|
"Fix the network problem first (see the finding this is grouped under).",
|
||||||
"Buffered data is uploaded automatically once the connection works.",
|
"Buffered data is uploaded automatically once the connection works.",
|
||||||
@@ -340,6 +351,7 @@ actions = [
|
|||||||
"Open the case and check the I2C cable/connector between the boards.",
|
"Open the case and check the I2C cable/connector between the boards.",
|
||||||
"If the cable is fine: the RTC module is faulty.",
|
"If the cable is fine: the RTC module is faulty.",
|
||||||
]
|
]
|
||||||
|
explained_by = ["kota.i2c_stuck"]
|
||||||
led = "BLUE"
|
led = "BLUE"
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
@@ -353,6 +365,7 @@ actions = [
|
|||||||
"Give the box Internet access: the clock is set automatically on the first connection.",
|
"Give the box Internet access: the clock is set automatically on the first connection.",
|
||||||
"If this comes back after every power-off: replace the RTC coin cell (inside the case).",
|
"If this comes back after every power-off: replace the RTC coin cell (inside the case).",
|
||||||
]
|
]
|
||||||
|
explained_by = ["kota.rtc_stopped"]
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
id = "i2c.errors"
|
id = "i2c.errors"
|
||||||
@@ -362,6 +375,7 @@ min_count = 2
|
|||||||
title = "I2C bus communication errors"
|
title = "I2C bus communication errors"
|
||||||
meaning = "Sensors are not answering reliably on the I2C bus: loose connector, damaged cable, or a failing sensor."
|
meaning = "Sensors are not answering reliably on the I2C bus: loose connector, damaged cable, or a failing sensor."
|
||||||
actions = ["Open the case and check the I2C cable/connectors between the boards."]
|
actions = ["Open the case and check the I2C cable/connectors between the boards."]
|
||||||
|
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
|
||||||
|
|
||||||
# ------------------------------------------------------------------ sensors
|
# ------------------------------------------------------------------ sensors
|
||||||
|
|
||||||
@@ -376,6 +390,7 @@ actions = [
|
|||||||
"Open the case and check the {sensor} connector/cable.",
|
"Open the case and check the {sensor} connector/cable.",
|
||||||
"If several sensors fail in turn: check the shared I2C cable.",
|
"If several sensors fail in turn: check the shared I2C cable.",
|
||||||
]
|
]
|
||||||
|
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
|
||||||
led = "RED"
|
led = "RED"
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
@@ -558,8 +573,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"
|
||||||
@@ -570,3 +585,148 @@ title = "A sensor returned no data"
|
|||||||
meaning = "One of the sensors skipped its reading; the preceding log lines name the sensor."
|
meaning = "One of the sensors skipped its reading; the preceding log lines name the sensor."
|
||||||
actions = ["Check the log lines just before this message for the sensor name."]
|
actions = ["Check the log lines just before this message for the sensor name."]
|
||||||
explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"]
|
explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"]
|
||||||
|
|
||||||
|
# ------------------------------------------------ structured KOTA: lines
|
||||||
|
# Firmware 1.0.16+ reports its state as "KOTA: <record> key=value ..." lines.
|
||||||
|
# These rules have no pattern: the engine raises them from the parsed fields
|
||||||
|
# (see diag/kota.py for the line formats). Named values in {braces} come
|
||||||
|
# from the line's fields.
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.i2c_stuck"
|
||||||
|
severity = "critical"
|
||||||
|
title = "I2C bus stuck (SDA={sda}, SCL={scl})"
|
||||||
|
meaning = "A data line of the sensor bus is held low before any sensor is used: a shorted or broken sensor cable, or a faulty sensor. LED blue, 2 pulses."
|
||||||
|
actions = [
|
||||||
|
"Open the case and check the I2C cable between the boards.",
|
||||||
|
"Unplug the sensors one by one to find the one holding the bus.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.sensor_missing"
|
||||||
|
split_by = "sensor"
|
||||||
|
severity = "error"
|
||||||
|
title = "{sensor} does not answer on the I2C bus"
|
||||||
|
meaning = "The start-up scan did not find the {sensor} at address {addr}. The other sensors answer, so the bus itself works: the {sensor} cable, connector or the sensor is faulty."
|
||||||
|
actions = [
|
||||||
|
"Open the case and re-seat the {sensor} cable/connector.",
|
||||||
|
"If it is still missing after a restart: replace the {sensor}.",
|
||||||
|
]
|
||||||
|
explained_by = ["kota.i2c_stuck"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.rtc_read_failed"
|
||||||
|
severity = "critical"
|
||||||
|
title = "Clock chip (RTC) cannot be read ({err})"
|
||||||
|
meaning = "The DS1307 clock does not answer. It is on the shared I2C bus, so a loose cable also causes this."
|
||||||
|
actions = [
|
||||||
|
"Open the case and check the I2C cable/connector between the boards.",
|
||||||
|
"If the cable is fine: replace the RTC module.",
|
||||||
|
]
|
||||||
|
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.rtc_stopped"
|
||||||
|
severity = "warning"
|
||||||
|
title = "Clock chip was stopped at start-up (time {time})"
|
||||||
|
meaning = "The RTC answers but its clock was not running - typical after its coin cell was removed or is empty. It is set again from the Internet once the box is online; until then readings are held."
|
||||||
|
actions = [
|
||||||
|
"Give the box Internet access: the clock is set automatically.",
|
||||||
|
"If this comes back after every power-off: replace the RTC coin cell (inside the case).",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.rtc_not_ticking"
|
||||||
|
severity = "error"
|
||||||
|
title = "Clock chip does not keep time"
|
||||||
|
meaning = "The clock was set from the Internet, but 2 s later it had not advanced correctly (running={running}, delta={delta}). The RTC module or its crystal is faulty."
|
||||||
|
actions = ["Replace the RTC module (inside the case)."]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.sd_unmounted"
|
||||||
|
severity = "critical"
|
||||||
|
title = "SD card not mounted ({err})"
|
||||||
|
meaning = "The firmware cannot use the SD card: missing, not seated, not FAT32 or damaged. Without it the box cannot read ENV.TXT. LED white, fast blinking."
|
||||||
|
actions = [
|
||||||
|
"Open the case and re-seat the SD card.",
|
||||||
|
"Re-provision the card with the tool (FAT32, MBR) or try a freshly provisioned card.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.sd_not_fat32"
|
||||||
|
severity = "warning"
|
||||||
|
title = "SD card file system is {fs}, not FAT32"
|
||||||
|
meaning = "The card works, but the provisioning standard is FAT32."
|
||||||
|
actions = ["Re-provision the card with the tool when convenient."]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.sd_full"
|
||||||
|
severity = "warning"
|
||||||
|
title = "SD card is almost full ({free} free)"
|
||||||
|
meaning = "Offline readings may soon not be stored."
|
||||||
|
actions = ["Check the buffered readings upload (Internet access), or replace the card."]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.buffer_backlog"
|
||||||
|
severity = "warning"
|
||||||
|
title = "{lines} readings are waiting on the SD card to be uploaded"
|
||||||
|
meaning = "The box stored readings while it could not upload them. They are sent automatically once the connection works."
|
||||||
|
actions = ["Fix the connection problem if there is one; watch the health line: the buffer should shrink."]
|
||||||
|
explained_by = ["kota.wifi_down", "matter.not_commissioned", "status.conn", "status.conn.1", "net.dns_fail", "influx.auth"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.wifi_down"
|
||||||
|
severity = "error"
|
||||||
|
min_count = 2
|
||||||
|
title = "Box has no Wi-Fi connection"
|
||||||
|
meaning = "The periodic health report says Wi-Fi is disconnected, so the box cannot upload data or receive updates."
|
||||||
|
actions = [
|
||||||
|
"Check the site Wi-Fi is on, 2.4 GHz and in range.",
|
||||||
|
"Matter boxes: check the box is still paired in the Matter app. Wi-Fi-from-SD boxes: check SSID/PASSWORD in ENV.TXT.",
|
||||||
|
]
|
||||||
|
explained_by = ["matter.not_commissioned", "wifi.setup_portal", "wifi.disconnected"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.weak_wifi"
|
||||||
|
severity = "warning"
|
||||||
|
title = "Weak Wi-Fi signal ({rssi} dBm)"
|
||||||
|
meaning = "The connection works but the signal is weak; uploads may fail intermittently. Below about -75 dBm is unreliable."
|
||||||
|
actions = ["Move the box or the access point closer, or add a repeater."]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.time_invalid"
|
||||||
|
severity = "warning"
|
||||||
|
title = "Box has no valid time - readings are on hold"
|
||||||
|
meaning = "Readings are only stored with a valid timestamp. The time comes from the RTC or from the Internet."
|
||||||
|
actions = [
|
||||||
|
"Give the box Internet access so it can set its clock.",
|
||||||
|
"If it is online and this persists: check the RTC findings.",
|
||||||
|
]
|
||||||
|
explained_by = ["kota.wifi_down", "matter.not_commissioned", "kota.rtc_not_ticking", "kota.rtc_read_failed", "kota.rtc_stopped", "rtc.clock_not_running", "net.dns_fail"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.no_upload"
|
||||||
|
severity = "error"
|
||||||
|
title = "Connected to Wi-Fi, but no data uploaded yet (5+ min)"
|
||||||
|
meaning = "The box has Wi-Fi but no upload to InfluxDB has succeeded yet."
|
||||||
|
actions = [
|
||||||
|
"Check the network has Internet (open a website on a phone on the same Wi-Fi).",
|
||||||
|
"Check INFLUX_URL / INFLUX_TOKEN in ENV.TXT (re-provision the SD card).",
|
||||||
|
]
|
||||||
|
explained_by = ["kota.time_invalid", "influx.auth", "influx.not_found", "status.conn.1", "net.dns_fail", "net.tls_fail", "influx.server_error"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.low_heap"
|
||||||
|
severity = "warning"
|
||||||
|
title = "Firmware memory is low ({heap} bytes free)"
|
||||||
|
meaning = "Internal memory is running low; the firmware may fail to start tasks and restart."
|
||||||
|
actions = ["Save the session log for the firmware team."]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "kota.ota_unregistered"
|
||||||
|
severity = "warning"
|
||||||
|
min_count = 3
|
||||||
|
title = "Online, but not registered for firmware updates"
|
||||||
|
meaning = "The box has Wi-Fi but is still not registered with the update server, so it will not receive new firmware."
|
||||||
|
actions = ["Check the network allows HTTPS to bin.kota-app.com; registration is retried automatically."]
|
||||||
|
explained_by = ["ota.registration_failed", "net.dns_fail", "net.tls_fail"]
|
||||||
Whitespace-only changes.
@@ -0,0 +1,260 @@
|
|||||||
|
"""Firmware images: validation, integrity, layout and the firmware folder.
|
||||||
|
|
||||||
|
The tool flashes ONLY the .bin files in its own firmware/ folder (next to
|
||||||
|
kota_tech/). There is no import and no per-user library: to offer a build,
|
||||||
|
the firmware team puts the file into that folder. Images are listed by the
|
||||||
|
compile date stored in the firmware itself (ESP-IDF app descriptor), newest
|
||||||
|
first, so the file name does not matter.
|
||||||
|
|
||||||
|
Two kinds:
|
||||||
|
* merged - bootloader at 0x0 + partition table + app (merged-binary.bin);
|
||||||
|
App-only or Full flashing;
|
||||||
|
* app - the application binary only (build/kota-sensorboard.bin);
|
||||||
|
App-only flashing, laid out like a merged image in the folder.
|
||||||
|
|
||||||
|
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 struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware"
|
||||||
|
|
||||||
|
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 # "Sep 28 2026 15:07:57" 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
|
||||||
|
|
||||||
|
@property
|
||||||
|
def compiled(self) -> str:
|
||||||
|
"""'2026-09-28 15:07' (ISO-like, sortable) or the raw descriptor text."""
|
||||||
|
t = self.built_at
|
||||||
|
return t.strftime("%Y-%m-%d %H:%M") if t else self.built
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FirmwareImage:
|
||||||
|
path: Path
|
||||||
|
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
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.path.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self) -> bytes:
|
||||||
|
return self.path.read_bytes()
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Choice text: compile date first (that is what the technician picks by)."""
|
||||||
|
kind = "" if self.kind == "merged" else ", app only"
|
||||||
|
return f"Compiled {self.app.compiled} - v{self.app.version} [{self.app.elf_sha[:8]}]{kind} ({self.name})"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 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, reference: FirmwareImage | None = None) -> FirmwareImage:
|
||||||
|
"""Validate a merged or app-only image. App-only images need a `reference`
|
||||||
|
(a merged 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 file, but there is no complete (merged) image in the folder "
|
||||||
|
"to take the flash layout from")
|
||||||
|
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, kind, hashlib.sha256(data).hexdigest(),
|
||||||
|
flash_size, parts, table, app_off, length, app)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- catalogue
|
||||||
|
|
||||||
|
def folder_images(folder: Path = FIRMWARE_DIR) -> tuple[list[FirmwareImage], list[str]]:
|
||||||
|
"""(valid images newest-compiled first, problems) for the .bin files in `folder`.
|
||||||
|
|
||||||
|
A broken or foreign file is reported, never offered. The same build
|
||||||
|
under two file names is offered once.
|
||||||
|
"""
|
||||||
|
if not folder.is_dir():
|
||||||
|
return [], [f"firmware folder not found: {folder}"]
|
||||||
|
files = sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == ".bin")
|
||||||
|
merged: list[FirmwareImage] = []
|
||||||
|
app_only: list[Path] = []
|
||||||
|
problems: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
merged.append(inspect_image(f))
|
||||||
|
except ImageError as exc:
|
||||||
|
if "app-only" in str(exc):
|
||||||
|
app_only.append(f)
|
||||||
|
else:
|
||||||
|
problems.append(f"{f.name}: {exc}")
|
||||||
|
except OSError as exc:
|
||||||
|
problems.append(f"{f.name}: cannot read ({exc})")
|
||||||
|
merged.sort(key=_newest_first)
|
||||||
|
reference = merged[0] if merged else None
|
||||||
|
images = list(merged)
|
||||||
|
for f in app_only:
|
||||||
|
try:
|
||||||
|
images.append(inspect_image(f, reference))
|
||||||
|
except (ImageError, OSError) as exc:
|
||||||
|
problems.append(f"{f.name}: {exc}")
|
||||||
|
images.sort(key=_newest_first)
|
||||||
|
unique: dict[str, FirmwareImage] = {}
|
||||||
|
for img in images:
|
||||||
|
if img.sha256 in unique:
|
||||||
|
problems.append(f"{img.name}: same file as {unique[img.sha256].name} (listed once)")
|
||||||
|
else:
|
||||||
|
unique[img.sha256] = img
|
||||||
|
return list(unique.values()), problems
|
||||||
|
|
||||||
|
|
||||||
|
def _newest_first(img: FirmwareImage) -> tuple:
|
||||||
|
# Unknown compile dates last; merged before app-only for the same date.
|
||||||
|
t = img.app.built_at
|
||||||
|
return (t is None, -t.timestamp() if t else 0.0, img.kind != "merged", img.name)
|
||||||
@@ -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,113 @@
|
|||||||
|
"""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
|
||||||
|
qr: str | None = None # exact payload reported by the box ("KOTA: matter qr=MT:...")
|
||||||
|
manual: str | None = None # exact 11-digit manual code reported by the box
|
||||||
|
|
||||||
|
@property
|
||||||
|
def from_box(self) -> bool:
|
||||||
|
return self.qr is not None
|
||||||
|
|
||||||
|
def qr_text(self) -> str:
|
||||||
|
if self.qr:
|
||||||
|
return self.qr
|
||||||
|
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:
|
||||||
|
if self.manual:
|
||||||
|
return self.manual
|
||||||
|
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], qr: str | None = None, manual: str | None = None) -> SetupPayload:
|
||||||
|
"""Firmware 1.0.16+ reports its exact QR payload and manual code
|
||||||
|
("KOTA: matter ... qr=MT:... manual=..."); older firmware: use the
|
||||||
|
advertised VID/PID/discriminator if seen; defaults otherwise."""
|
||||||
|
base = SetupPayload()
|
||||||
|
for text in reversed(lines):
|
||||||
|
if m := _ADVERTISE.search(text):
|
||||||
|
base = SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True)
|
||||||
|
break
|
||||||
|
if qr:
|
||||||
|
return SetupPayload(base.vendor_id, base.product_id, base.discriminator, from_log=True,
|
||||||
|
qr=qr, manual=manual if manual and manual.isdigit() else None)
|
||||||
|
return base
|
||||||
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,261 @@
|
|||||||
|
"""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] = []
|
||||||
|
# Windows says "FAT32"; macOS diskutil says "DOS_FAT_32" / "Windows_FAT_32" / "MS-DOS FAT32".
|
||||||
|
fs = filesystem.upper().replace("_", "").replace(" ", "")
|
||||||
|
if fs and "FAT32" not in fs and fs not in ("FAT", "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,155 @@
|
|||||||
|
"""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 = 4800 # the service form and Code.gs accept 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),
|
||||||
|
# firmware 1.0.16+ (KOTA: lines, status, error IDs)
|
||||||
|
("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
|
||||||
|
("kota.low_heap", SOFTWARE),
|
||||||
|
("kota.i2c_stuck", HARDWARE), ("kota.sensor_missing", HARDWARE), ("kota.rtc_", HARDWARE),
|
||||||
|
("kota.sd_", HARDWARE), ("status.error", HARDWARE), ("status.rtc", HARDWARE), ("status.sd", HARDWARE),
|
||||||
|
("kota.wifi_down", CONNECTIVITY), ("kota.weak_wifi", CONNECTIVITY), ("kota.no_upload", CONNECTIVITY),
|
||||||
|
("kota.ota_unregistered", CONNECTIVITY), ("kota.buffer_backlog", CONNECTIVITY), ("status.conn", CONNECTIVITY),
|
||||||
|
("kota.time_invalid", CHECK),
|
||||||
|
*((f"eid.{m}", HARDWARE) for m in ("SD", "ENV", "RTC", "BME", "SEN", "AS", "MIC", "FLK", "SPI")),
|
||||||
|
*((f"eid.{m}", CONNECTIVITY) for m in ("INF", "WIFI", "OTA", "PROV")),
|
||||||
|
("eid.", SOFTWARE),
|
||||||
|
("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
|
||||||
|
|
||||||
|
|
||||||
|
BOX_MARK = {"ok": "✔", "warn": "!", "bad": "✖", "info": "i"} # same marks as the Diagnosis panel
|
||||||
|
SD_WIFI = {True: "reads Wi-Fi settings from the SD card",
|
||||||
|
False: "does not read Wi-Fi settings from the SD card",
|
||||||
|
None: "Wi-Fi-from-SD support unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
def service_note(report: Report, limit: int = NOTE_LIMIT) -> str:
|
||||||
|
"""The Diagnosis panel as text: verdict, LED, firmware, box self-report,
|
||||||
|
start-up progress with times, findings with details and when they were seen.
|
||||||
|
|
||||||
|
Falls back to shorter finding details when the full text does not fit the
|
||||||
|
service form (consequences' explanations first, then all), then truncates.
|
||||||
|
"""
|
||||||
|
for detail in ("all", "roots", "none"):
|
||||||
|
text = _note(report, detail)
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[:limit - 20].rstrip() + "\n...(shortened)"
|
||||||
|
|
||||||
|
|
||||||
|
def _note(report: Report, detail: str) -> str:
|
||||||
|
fw = report.firmware
|
||||||
|
source = " (reported by the box)" if report.led_exact else ""
|
||||||
|
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", "",
|
||||||
|
f"Status LED should be: {report.led_text()}{source}"]
|
||||||
|
if report.led_reason:
|
||||||
|
lines.append(f" from: {report.led_reason}")
|
||||||
|
lines += ["", f"Firmware: {fw.describe()}", f" {SD_WIFI[fw.supports_sd_wifi]}"]
|
||||||
|
if report.box:
|
||||||
|
lines += ["", "Box self-report (KOTA lines):"]
|
||||||
|
lines += [f" {BOX_MARK.get(st, '-')} {topic}: {text}" for topic, text, st in report.box]
|
||||||
|
lines += ["", "Start-up progress (current boot):"]
|
||||||
|
lines += [f" ✔ {label} ({t / 1000:.1f} s)" if t is not None else f" ○ {label}"
|
||||||
|
for _, label, t in report.milestones]
|
||||||
|
lines += ["", "Findings - root causes first:" if report.roots else "Findings: no problems detected in the log"]
|
||||||
|
for rc in report.roots:
|
||||||
|
lines += _finding(rc.finding, "", detail in ("all", "roots"))
|
||||||
|
for c in rc.consequences:
|
||||||
|
lines += _finding(c, " ↳ ", detail == "all")
|
||||||
|
for title, items in (("Seen in earlier boots only", report.earlier),
|
||||||
|
("Resolved during this session", report.resolved)):
|
||||||
|
if items:
|
||||||
|
lines += ["", f"{title} ({len(items)}):"]
|
||||||
|
lines += [f" - [{f.severity.label}] {f.title} - {f.seen_text()}" for f in items]
|
||||||
|
if report.technician:
|
||||||
|
lines += ["", "Technician checks:"]
|
||||||
|
lines += [f" * {t}" for t in report.technician[-12:]]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _finding(f, prefix: str, detailed: bool) -> list[str]:
|
||||||
|
ind = " " * len(prefix) + " "
|
||||||
|
out = [f"{prefix}[{f.severity.label}] {f.title}"]
|
||||||
|
if detailed:
|
||||||
|
out.append(f"{ind}What it means: {f.meaning}")
|
||||||
|
if f.actions:
|
||||||
|
out.append(f"{ind}What to do:")
|
||||||
|
out += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)]
|
||||||
|
out += [f"{ind}Error {eid}" + (f": {a}" if a else "") for eid, a in f.error_ids.items()]
|
||||||
|
out.append(f"{ind}{f.seen_text()}")
|
||||||
|
if f.evidence:
|
||||||
|
out.append(f"{ind}Log: {f.evidence[-1]}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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"))
|
||||||
+87
-84
@@ -1,84 +1,87 @@
|
|||||||
"""Quick-start cheat sheet shown on launch.
|
"""Quick-start cheat sheet shown on launch.
|
||||||
|
|
||||||
Targets the DEPLOYED firmware: new_system.bin, app "kota-sensorboard"
|
LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md,
|
||||||
1.0, built Jun 29 2026 11:03:32, ELF SHA256 45fa9d5b9... (verified on a
|
status.cpp Status::pattern_for): colour AND pattern name the fault. Older
|
||||||
live board). That build has no Wi-Fi setup portal / SSID-on-SD support;
|
boxes (v1.0, built Jun 29 2026) show the same colours, always solid.
|
||||||
those exist only in the newer, unbuilt source. The status LED is always
|
"""
|
||||||
SOLID; the separate small LED toggles every second while alive.
|
|
||||||
"""
|
# (swatch colour, LED name, pattern -> meaning, first action)
|
||||||
|
LED_TABLE = [
|
||||||
# (swatch colour, LED name, meaning, first action)
|
("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."),
|
||||||
LED_TABLE = [
|
("#1fc8d8", "Cyan",
|
||||||
("#2e9e44", "Green", "Everything OK.", "Nothing to do."),
|
"breathing: not paired in Matter yet<br>fast blink: a phone is connected for pairing.",
|
||||||
("#8e44ad", "Purple",
|
"Pair the box in the Matter app (QR sticker or the QR code in the tool)."),
|
||||||
"No Internet / InfluxDB or OTA server unreachable.",
|
("#f4f4f4", "White",
|
||||||
"Check the box is commissioned in the Matter app (sticker QR code) and the "
|
"fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>"
|
||||||
"site Wi-Fi has Internet. Board reboots itself after ~2.5 min of purple."),
|
"breathing: Wi-Fi setup portal.",
|
||||||
("#d64541", "Red",
|
"SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."),
|
||||||
"Sensor or hardware fault (BME688, SEN66, AS7341, microphone, flicker) "
|
("#d64541", "Red",
|
||||||
"or failed self-check after update.",
|
"1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed<br>"
|
||||||
"Connect serial - the log names the failing sensor. Check sensor cables."),
|
"solid: other internal error / out of memory.",
|
||||||
("#2f6fd6", "Blue",
|
"Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."),
|
||||||
"Clock (RTC DS1307) failed to start. The RTC is the first I2C device, "
|
("#2f6fd6", "Blue",
|
||||||
"so a broken I2C cable also shows blue.",
|
"1 pulse: clock (RTC) not responding<br>2 pulses: I2C bus stuck (shorted / broken sensor cable).",
|
||||||
"Check the I2C harness first, then the RTC module / coin cell."),
|
"Check the I2C cable between the boards first, then the RTC module."),
|
||||||
("#e6c229", "Yellow", "Firmware update downloading.", "Do not switch off. Wait."),
|
("#8e44ad", "Purple",
|
||||||
("#f08c1a", "Orange", "Self-check after an update (~2.5 min).",
|
"solid: no network<br>blinking: server rejected the box (wrong ENV.TXT credentials).",
|
||||||
"Wait; should turn green (or red on failure)."),
|
"Solid: check Wi-Fi / Internet. Blinking: re-provision the SD card with the current ENV.TXT."),
|
||||||
("#f4f4f4", "White",
|
("#e6c229", "Yellow", "blinking, faster over time: downloading an update.", "Do not switch off. Wait."),
|
||||||
"SD card missing/unreadable or ENV.TXT incomplete - or a factory reset is "
|
("#f08c1a", "Orange", "solid: self-check after an update (~2.5 min).",
|
||||||
"in progress (Matter button held 5 s).",
|
"Wait; should turn green (or red on failure)."),
|
||||||
"If nobody pressed the Matter button: the SD card is inside the case - "
|
("#444444", "Off", "Firmware did not start (or crashed very early).",
|
||||||
"unscrew, re-seat or re-provision it."),
|
"Check power switch & USB cable. If the small LED is also dark: re-flash."),
|
||||||
("#444444", "Off", "Firmware did not start (or crashed very early).",
|
]
|
||||||
"Check power switch & USB cable. If the small LED is also dark: re-flash."),
|
|
||||||
]
|
HINTS = [
|
||||||
|
"<b>Before a self-restart</b> the LED flashes the error colour 3 times; <b>after the restart</b> it "
|
||||||
HINTS = [
|
"shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press "
|
||||||
"<b>Colour keeps changing / LED goes dark every few seconds</b> = reboot loop. "
|
"<i>Reset & capture</i>: the tool reads the restart reason from the box.",
|
||||||
"Connect serial and press <i>Reset & capture</i>: the last lines before the "
|
"<b>Small LED next to the RGB LED</b> (heartbeat): blinks once per second while online, "
|
||||||
"reboot name the cause.",
|
"double-blinks while offline. Dark or steady = firmware hung or not running.",
|
||||||
"<b>Small LED next to the RGB LED</b> blinks once per second while the "
|
"<b>Serial monitor</b>: lines starting with <b>KOTA:</b> are the box's own diagnosis (boot, I2C, SD, "
|
||||||
"firmware runs. Not blinking = firmware hung or not running.",
|
"clock, Matter, microphone, status, LED, and a health line every 60 s). The Diagnosis panel reads them "
|
||||||
"<b>Outside the case</b>: power switch and the Matter button. Holding the "
|
"directly; filter the view with <i>KOTA diagnostics + errors</i>. Error lines carry a code like "
|
||||||
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
|
"<b>[E-SD-03]</b> - the tool shows the recommended action for it.",
|
||||||
"network and must be commissioned again with the sticker QR code).",
|
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
|
||||||
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
|
"(off = no noise measurements), and the Matter button. Holding the "
|
||||||
"Normally not needed - the tool resets the board over USB.",
|
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
|
||||||
"<b>Gathering messages for the first ~2 minutes</b> are normal "
|
"network and must be commissioned again with the sticker QR code).",
|
||||||
"(the air-quality sensor warms up for 120 s).",
|
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
|
||||||
]
|
"Normally not needed - the tool resets the board over USB.",
|
||||||
|
"<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
|
||||||
STEPS = [
|
"by its compile date.",
|
||||||
"Switch the sensorbox <b>power switch ON</b>.",
|
]
|
||||||
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
|
|
||||||
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
STEPS = [
|
||||||
"<b>Connect</b>.",
|
"Switch the sensorbox <b>power switch ON</b>.",
|
||||||
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
|
||||||
"Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
|
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
||||||
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
|
"<b>Connect</b>.",
|
||||||
]
|
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
||||||
|
"Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
|
||||||
|
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
|
||||||
def cheatsheet_html() -> str:
|
]
|
||||||
rows = "".join(
|
|
||||||
f"<tr><td style='padding:6px'><span style='background:{c};"
|
|
||||||
f"border:1px solid #888'> </span> <b>{name}</b></td>"
|
def cheatsheet_html() -> str:
|
||||||
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
|
rows = "".join(
|
||||||
for c, name, meaning, action in LED_TABLE
|
f"<tr><td style='padding:6px'><span style='background:{c};"
|
||||||
)
|
f"border:1px solid #888'> </span> <b>{name}</b></td>"
|
||||||
steps = "".join(f"<li>{s}</li>" for s in STEPS)
|
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
|
||||||
hints = "".join(f"<li>{h}</li>" for h in HINTS)
|
for c, name, meaning, action in LED_TABLE
|
||||||
return f"""
|
)
|
||||||
<h2>Sensorbox quick start</h2>
|
steps = "".join(f"<li>{s}</li>" for s in STEPS)
|
||||||
<ol>{steps}</ol>
|
hints = "".join(f"<li>{h}</li>" for h in HINTS)
|
||||||
<h3>RGB status LED (always solid colour)</h3>
|
return f"""
|
||||||
<table border='1' cellspacing='0' cellpadding='0' width='100%'>
|
<h2>Sensorbox quick start</h2>
|
||||||
<tr><th align='left' style='padding:6px'>LED</th>
|
<ol>{steps}</ol>
|
||||||
<th align='left' style='padding:6px'>Most likely meaning</th>
|
<h3>RGB status LED - colour and pattern</h3>
|
||||||
<th align='left' style='padding:6px'>First action</th></tr>
|
<table border='1' cellspacing='0' cellpadding='0' width='100%'>
|
||||||
{rows}
|
<tr><th align='left' style='padding:6px'>LED</th>
|
||||||
</table>
|
<th align='left' style='padding:6px'>Pattern: meaning</th>
|
||||||
<h3>Good to know</h3>
|
<th align='left' style='padding:6px'>First action</th></tr>
|
||||||
<ul>{hints}</ul>
|
{rows}
|
||||||
"""
|
</table>
|
||||||
|
<h3>Good to know</h3>
|
||||||
|
<ul>{hints}</ul>
|
||||||
|
"""
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Diagnostics side panel: renders a diag.engine.Report.
|
"""Diagnostics side panel: renders a diag.engine.Report.
|
||||||
|
|
||||||
Layout: verdict banner, expected LED, firmware, start-up milestones, a
|
Layout: verdict banner, expected LED (colour + pattern + meaning when the
|
||||||
findings tree (root causes; their consequences as children; titles only)
|
box reports it), firmware, the box's own self-report (KOTA lines), start-up
|
||||||
and a wrapped detail pane for the selected finding.
|
milestones, a findings tree (root causes; their consequences as children;
|
||||||
|
titles only) and a wrapped detail pane for the selected finding.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,6 +16,7 @@ from PySide6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QTextBrowser,
|
QTextBrowser,
|
||||||
@@ -40,6 +42,7 @@ BANNER = {
|
|||||||
None: ("#d8f0dd", "#1b5e2a"),
|
None: ("#d8f0dd", "#1b5e2a"),
|
||||||
}
|
}
|
||||||
_KEY = Qt.ItemDataRole.UserRole
|
_KEY = Qt.ItemDataRole.UserRole
|
||||||
|
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
|
||||||
|
|
||||||
|
|
||||||
def _heading(text: str) -> QLabel:
|
def _heading(text: str) -> QLabel:
|
||||||
@@ -76,6 +79,11 @@ class DiagnosticsPanel(QWidget):
|
|||||||
self.firmware = QLabel(wordWrap=True)
|
self.firmware = QLabel(wordWrap=True)
|
||||||
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
|
||||||
|
self.box_title = _heading("Box self-report (KOTA lines)")
|
||||||
|
self.box = QLabel(wordWrap=True)
|
||||||
|
self.box.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
|
||||||
self.milestones = QListWidget()
|
self.milestones = QListWidget()
|
||||||
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
|
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||||
@@ -99,6 +107,11 @@ 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(self.box_title)
|
||||||
|
lay.addWidget(self.box)
|
||||||
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"))
|
||||||
@@ -124,8 +137,11 @@ class DiagnosticsPanel(QWidget):
|
|||||||
self.led_swatch.setStyleSheet(
|
self.led_swatch.setStyleSheet(
|
||||||
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
|
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
|
||||||
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
|
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
|
||||||
|
pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else ""
|
||||||
|
meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning else ""
|
||||||
|
source = " <small style='color:#1b7a33'>(reported by the box)</small>" if r.led_exact else ""
|
||||||
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
||||||
self.led_text.setText(f"<b>{name}</b>{ask}{reason}")
|
self.led_text.setText(f"<b>{name}{pattern}</b>{meaning}{source}{ask}{reason}")
|
||||||
|
|
||||||
fw = r.firmware
|
fw = r.firmware
|
||||||
sd = {True: "reads Wi-Fi settings from the SD card",
|
sd = {True: "reads Wi-Fi settings from the SD card",
|
||||||
@@ -133,6 +149,13 @@ class DiagnosticsPanel(QWidget):
|
|||||||
None: "Wi-Fi-from-SD support unknown"}
|
None: "Wi-Fi-from-SD support unknown"}
|
||||||
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
||||||
|
|
||||||
|
self.box_title.setVisible(bool(r.box))
|
||||||
|
self.box.setVisible(bool(r.box))
|
||||||
|
self.box.setText("<table cellspacing='0' cellpadding='1'>" + "".join(
|
||||||
|
f"<tr><td style='color:{BOX_STATE[st][0]}'><b>{BOX_STATE[st][1]}</b> </td>"
|
||||||
|
f"<td><b>{_esc(topic)}</b> </td><td>{_esc(text)}</td></tr>"
|
||||||
|
for topic, text, st in r.box) + "</table>")
|
||||||
|
|
||||||
self.milestones.clear()
|
self.milestones.clear()
|
||||||
for _, label, t in r.milestones:
|
for _, label, t in r.milestones:
|
||||||
done = t is not None
|
done = t is not None
|
||||||
@@ -214,16 +237,15 @@ class DiagnosticsPanel(QWidget):
|
|||||||
def _detail_html(f: Finding) -> str:
|
def _detail_html(f: Finding) -> str:
|
||||||
colour = SEV_COLOR.get(f.severity, "#333")
|
colour = SEV_COLOR.get(f.severity, "#333")
|
||||||
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
||||||
seen = f"Seen {f.count}x"
|
seen = _esc(f.seen_text())
|
||||||
if f.first_uptime_ms is not None:
|
|
||||||
seen += f", first at {f.first_uptime_ms / 1000:.1f} s after start"
|
|
||||||
if f.resolved and f.resolved_by:
|
|
||||||
seen += f" - resolved ({_esc(f.resolved_by)})"
|
|
||||||
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
evidence = "<br>".join(_esc(e) for e in f.evidence)
|
||||||
|
codes = "".join(f"<li><b>{_esc(eid)}</b>" + (f" - {_esc(a)}" if a else "") + "</li>"
|
||||||
|
for eid, a in f.error_ids.items())
|
||||||
return (
|
return (
|
||||||
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
|
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
|
||||||
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
|
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
|
||||||
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
|
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
|
||||||
|
+ (f"<p><b>Firmware error codes</b> (error_ids.csv):</p><ul>{codes}</ul>" if codes else "")
|
||||||
+ f"<p style='color:#666'>{seen}</p>"
|
+ f"<p style='color:#666'>{seen}</p>"
|
||||||
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||||||
if evidence else "")
|
if evidence else "")
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Flash firmware tab: only the .bin files in the tool's firmware/ folder,
|
||||||
|
chosen by their compile date; 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, QUrl, Signal
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QButtonGroup,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.session import SESSIONS_DIR
|
||||||
|
from ..diag.firmware import local_build, register_local_build, same_build
|
||||||
|
from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_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 - only files in the tool's firmware/ folder
|
||||||
|
fwbox = QGroupBox("2. Firmware (by compile date)")
|
||||||
|
self.image_combo = QComboBox(minimumWidth=360)
|
||||||
|
self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
|
||||||
|
self.reload_btn = QPushButton("Refresh list")
|
||||||
|
self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)")
|
||||||
|
self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
|
||||||
|
self.folder_btn = QPushButton("Open firmware folder")
|
||||||
|
self.folder_btn.clicked.connect(
|
||||||
|
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
|
||||||
|
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.reload_btn)
|
||||||
|
brow.addWidget(self.folder_btn)
|
||||||
|
brow.addStretch(1)
|
||||||
|
fl.addLayout(brow)
|
||||||
|
fl.addWidget(self.image_info)
|
||||||
|
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 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._images, self._problems = folder_images()
|
||||||
|
for img in self._images: # lets the Serial monitor show a box build's compile date
|
||||||
|
register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
|
||||||
|
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 file in the firmware folder")
|
||||||
|
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0)
|
||||||
|
self.image_combo.setCurrentIndex(idx) # default: newest compile date
|
||||||
|
self.image_combo.blockSignals(False)
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def _selected_sha(self) -> str | None:
|
||||||
|
img = self._selected_image()
|
||||||
|
return img.sha256 if img else None
|
||||||
|
|
||||||
|
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
|
||||||
|
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
|
||||||
|
info = (f"<b>Compiled {a.built}</b> · {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
|
||||||
|
f"<small>file firmware/{img.name} · build {a.elf_sha[:8]} · "
|
||||||
|
f"SHA-256 {img.sha256[:16]}... · checksum OK</small>")
|
||||||
|
else:
|
||||||
|
info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>"
|
||||||
|
"and press <b>Refresh list</b>. Only files in this folder can be flashed.")
|
||||||
|
if self._problems:
|
||||||
|
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
|
||||||
|
+ "; ".join(self._problems) + "</span>")
|
||||||
|
self.image_info.setText(info)
|
||||||
|
# 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 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()}"
|
||||||
|
if not img:
|
||||||
|
return msg
|
||||||
|
if same_build(fw.elf_sha, img.app.elf_sha):
|
||||||
|
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
|
||||||
|
local = local_build(fw.elf_sha)
|
||||||
|
box_built, img_built = _parse_built(fw.built or (local[1] if local else None)), 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 image (pairing and OTA registration are erased)")
|
||||||
|
if QMessageBox.question(self, "Flash firmware",
|
||||||
|
f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\n"
|
||||||
|
f"File: firmware/{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.reload_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[:8]
|
||||||
|
done = same_build(fw.elf_sha, self._image.app.elf_sha)
|
||||||
|
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[:8] 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
|
||||||
+225
-216
@@ -1,216 +1,225 @@
|
|||||||
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import QSize, Qt
|
from PySide6.QtCore import QSize, Qt
|
||||||
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QProgressBar,
|
QProgressBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..diag.engine import LED, DiagnosticsEngine
|
from ..diag.engine import LED, DiagnosticsEngine
|
||||||
from ..diag.guides import GuideRunner, recommended
|
from ..diag.guides import GuideRunner, recommended
|
||||||
|
from ..matter.payload import SetupPayload
|
||||||
END_STYLE = { # end severity -> (border, background)
|
from .matter_qr import MatterQrWidget
|
||||||
"ok": ("#2e9e44", "#e3f4e7"),
|
|
||||||
"info": ("#2e6da4", "#e3edf7"),
|
END_STYLE = { # end severity -> (border, background)
|
||||||
"warning": ("#c77c02", "#fdf0d6"),
|
"ok": ("#2e9e44", "#e3f4e7"),
|
||||||
"error": ("#d9534f", "#f9e0df"),
|
"info": ("#2e6da4", "#e3edf7"),
|
||||||
"critical": ("#b03a2e", "#f6d5d1"),
|
"warning": ("#c77c02", "#fdf0d6"),
|
||||||
}
|
"error": ("#d9534f", "#f9e0df"),
|
||||||
|
"critical": ("#b03a2e", "#f6d5d1"),
|
||||||
|
}
|
||||||
def _swatch(key: str, size: int = 22) -> QIcon:
|
|
||||||
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
|
|
||||||
pm = QPixmap(size, size)
|
def _swatch(key: str, size: int = 22) -> QIcon:
|
||||||
pm.fill(QColor(0, 0, 0, 0))
|
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
|
||||||
p = QPainter(pm)
|
pm = QPixmap(size, size)
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
pm.fill(QColor(0, 0, 0, 0))
|
||||||
if key == "CYCLING":
|
p = QPainter(pm)
|
||||||
g = QLinearGradient(0, 0, size, 0)
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
if key == "CYCLING":
|
||||||
g.setColorAt(stop, QColor(c))
|
g = QLinearGradient(0, 0, size, 0)
|
||||||
p.setBrush(QBrush(g))
|
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
||||||
else:
|
g.setColorAt(stop, QColor(c))
|
||||||
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
p.setBrush(QBrush(g))
|
||||||
p.setPen(QColor("#777777"))
|
else:
|
||||||
p.drawEllipse(1, 1, size - 2, size - 2)
|
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
||||||
p.end()
|
p.setPen(QColor("#777777"))
|
||||||
return QIcon(pm)
|
p.drawEllipse(1, 1, size - 2, size - 2)
|
||||||
|
p.end()
|
||||||
|
return QIcon(pm)
|
||||||
def _esc(s: str) -> str:
|
|
||||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
||||||
|
def _esc(s: str) -> str:
|
||||||
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
class GuidePanel(QWidget):
|
|
||||||
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None):
|
|
||||||
super().__init__(parent)
|
class GuidePanel(QWidget):
|
||||||
self.runner, self.engine = runner, engine
|
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None,
|
||||||
self._shown_key: tuple | None = None
|
matter_payload=None):
|
||||||
|
super().__init__(parent)
|
||||||
# --- picker (no guide running)
|
self.runner, self.engine = runner, engine
|
||||||
self.picker = QWidget()
|
self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
|
||||||
pl = QVBoxLayout(self.picker)
|
self._shown_key: tuple | None = None
|
||||||
pl.setContentsMargins(0, 0, 0, 0)
|
|
||||||
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
# --- picker (no guide running)
|
||||||
self.guide_list = QListWidget()
|
self.picker = QWidget()
|
||||||
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
pl = QVBoxLayout(self.picker)
|
||||||
pl.addWidget(self.guide_list, 1)
|
pl.setContentsMargins(0, 0, 0, 0)
|
||||||
self.start_btn = QPushButton("Start selected check")
|
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
||||||
self.start_btn.clicked.connect(self._start_selected)
|
self.guide_list = QListWidget()
|
||||||
pl.addWidget(self.start_btn)
|
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
||||||
|
pl.addWidget(self.guide_list, 1)
|
||||||
# --- running guide
|
self.start_btn = QPushButton("Start selected check")
|
||||||
self.card = QFrame()
|
self.start_btn.clicked.connect(self._start_selected)
|
||||||
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
pl.addWidget(self.start_btn)
|
||||||
cl = QVBoxLayout(self.card)
|
|
||||||
self.guide_title = QLabel()
|
# --- running guide
|
||||||
self.guide_title.setStyleSheet("color:#666")
|
self.card = QFrame()
|
||||||
self.step_text = QLabel(wordWrap=True)
|
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
||||||
f = self.step_text.font()
|
cl = QVBoxLayout(self.card)
|
||||||
f.setPointSize(f.pointSize() + 2)
|
self.guide_title = QLabel()
|
||||||
self.step_text.setFont(f)
|
self.guide_title.setStyleSheet("color:#666")
|
||||||
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
self.step_text = QLabel(wordWrap=True)
|
||||||
self.options = QVBoxLayout()
|
f = self.step_text.font()
|
||||||
self.wait_bar = QProgressBar()
|
f.setPointSize(f.pointSize() + 2)
|
||||||
self.wait_bar.setTextVisible(False)
|
self.step_text.setFont(f)
|
||||||
self.wait_label = QLabel()
|
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
||||||
self.note = QLabel(wordWrap=True)
|
self.options = QVBoxLayout()
|
||||||
self.note.setStyleSheet("color:#b03a2e")
|
self.wait_bar = QProgressBar()
|
||||||
self.continue_btn = QPushButton("Continue")
|
self.wait_bar.setTextVisible(False)
|
||||||
self.continue_btn.clicked.connect(self._proceed)
|
self.wait_label = QLabel()
|
||||||
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
self.note = QLabel(wordWrap=True)
|
||||||
self.skip_btn.clicked.connect(self._proceed)
|
self.note.setStyleSheet("color:#b03a2e")
|
||||||
self.end_box = QLabel(wordWrap=True)
|
self.continue_btn = QPushButton("Continue")
|
||||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
self.continue_btn.clicked.connect(self._proceed)
|
||||||
self.end_box.setMargin(10)
|
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
||||||
for w in (self.guide_title, self.step_text):
|
self.skip_btn.clicked.connect(self._proceed)
|
||||||
cl.addWidget(w)
|
self.end_box = QLabel(wordWrap=True)
|
||||||
cl.addLayout(self.options)
|
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||||
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
self.end_box.setMargin(10)
|
||||||
cl.addWidget(w)
|
self.qr = MatterQrWidget(SetupPayload(), 220)
|
||||||
cl.addStretch(1)
|
for w in (self.guide_title, self.step_text, self.qr):
|
||||||
nav = QHBoxLayout()
|
cl.addWidget(w)
|
||||||
self.back_btn = QPushButton("← Back")
|
cl.addLayout(self.options)
|
||||||
self.back_btn.clicked.connect(self._back)
|
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
||||||
self.done_btn = QPushButton("Back to checks list")
|
cl.addWidget(w)
|
||||||
self.done_btn.clicked.connect(self._cancel)
|
cl.addStretch(1)
|
||||||
nav.addWidget(self.back_btn)
|
nav = QHBoxLayout()
|
||||||
nav.addStretch(1)
|
self.back_btn = QPushButton("← Back")
|
||||||
nav.addWidget(self.done_btn)
|
self.back_btn.clicked.connect(self._back)
|
||||||
cl.addLayout(nav)
|
self.done_btn = QPushButton("Back to checks list")
|
||||||
|
self.done_btn.clicked.connect(self._cancel)
|
||||||
lay = QVBoxLayout(self)
|
nav.addWidget(self.back_btn)
|
||||||
lay.setContentsMargins(4, 0, 0, 0)
|
nav.addStretch(1)
|
||||||
lay.addWidget(self.picker, 1)
|
nav.addWidget(self.done_btn)
|
||||||
lay.addWidget(self.card, 1)
|
cl.addLayout(nav)
|
||||||
self.refresh()
|
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
# ------------------------------------------------------------ actions
|
lay.setContentsMargins(4, 0, 0, 0)
|
||||||
def _start_selected(self) -> None:
|
lay.addWidget(self.picker, 1)
|
||||||
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
lay.addWidget(self.card, 1)
|
||||||
if it:
|
self.refresh()
|
||||||
self._start(it.data(Qt.ItemDataRole.UserRole))
|
|
||||||
|
# ------------------------------------------------------------ actions
|
||||||
def _start(self, guide_id: str) -> None:
|
def _start_selected(self) -> None:
|
||||||
self.runner.start(guide_id)
|
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
||||||
self.refresh()
|
if it:
|
||||||
|
self._start(it.data(Qt.ItemDataRole.UserRole))
|
||||||
def _answer(self, key: str) -> None:
|
|
||||||
self.runner.answer(key)
|
def _start(self, guide_id: str) -> None:
|
||||||
self.refresh()
|
self.runner.start(guide_id)
|
||||||
|
self.refresh()
|
||||||
def _proceed(self) -> None:
|
|
||||||
self.runner.proceed()
|
def _answer(self, key: str) -> None:
|
||||||
self.refresh()
|
self.runner.answer(key)
|
||||||
|
self.refresh()
|
||||||
def _back(self) -> None:
|
|
||||||
self.runner.back()
|
def _proceed(self) -> None:
|
||||||
self.refresh()
|
self.runner.proceed()
|
||||||
|
self.refresh()
|
||||||
def _cancel(self) -> None:
|
|
||||||
self.runner.cancel()
|
def _back(self) -> None:
|
||||||
self.refresh()
|
self.runner.back()
|
||||||
|
self.refresh()
|
||||||
# ------------------------------------------------------------- render
|
|
||||||
def refresh(self) -> None:
|
def _cancel(self) -> None:
|
||||||
"""Called after every interaction and periodically (timeouts, countdown)."""
|
self.runner.cancel()
|
||||||
self.runner.tick()
|
self.refresh()
|
||||||
v = self.runner.view()
|
|
||||||
running = v.guide is not None
|
# ------------------------------------------------------------- render
|
||||||
self.picker.setVisible(not running)
|
def refresh(self) -> None:
|
||||||
self.card.setVisible(running)
|
"""Called after every interaction and periodically (timeouts, countdown)."""
|
||||||
if not running:
|
self.runner.tick()
|
||||||
self._fill_picker()
|
v = self.runner.view()
|
||||||
self._shown_key = None
|
running = v.guide is not None
|
||||||
return
|
self.picker.setVisible(not running)
|
||||||
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
self.card.setVisible(running)
|
||||||
if key != self._shown_key:
|
if not running:
|
||||||
self._shown_key = key
|
self._fill_picker()
|
||||||
self._render_step(v)
|
self._shown_key = None
|
||||||
if v.waiting:
|
return
|
||||||
total = v.step.timeout_s
|
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
||||||
self.wait_bar.setMaximum(int(total))
|
if key != self._shown_key:
|
||||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
self._shown_key = key
|
||||||
m, s = divmod(int(v.remaining_s), 60)
|
self._render_step(v)
|
||||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
if v.waiting:
|
||||||
self.note.setText(v.note)
|
total = v.step.timeout_s
|
||||||
self.note.setVisible(bool(v.note))
|
self.wait_bar.setMaximum(int(total))
|
||||||
self.back_btn.setEnabled(v.can_back)
|
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||||
|
m, s = divmod(int(v.remaining_s), 60)
|
||||||
def _fill_picker(self) -> None:
|
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
||||||
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
self.note.setText(v.note)
|
||||||
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
self.note.setVisible(bool(v.note))
|
||||||
if [w[0] for w in wanted] == current:
|
self.back_btn.setEnabled(v.can_back)
|
||||||
return
|
|
||||||
self.guide_list.clear()
|
def _fill_picker(self) -> None:
|
||||||
for gid, title, summary in wanted:
|
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
||||||
it = QListWidgetItem(f"{title}\n {summary}")
|
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
||||||
it.setData(Qt.ItemDataRole.UserRole, gid)
|
if [w[0] for w in wanted] == current:
|
||||||
self.guide_list.addItem(it)
|
return
|
||||||
self.guide_list.setCurrentRow(0)
|
self.guide_list.clear()
|
||||||
|
for gid, title, summary in wanted:
|
||||||
def _render_step(self, v) -> None:
|
it = QListWidgetItem(f"{title}\n {summary}")
|
||||||
self.guide_title.setText(v.guide.title)
|
it.setData(Qt.ItemDataRole.UserRole, gid)
|
||||||
while self.options.count():
|
self.guide_list.addItem(it)
|
||||||
w = self.options.takeAt(0).widget()
|
self.guide_list.setCurrentRow(0)
|
||||||
if w:
|
|
||||||
# Detach now; deleteLater alone leaves it visible until the
|
def _render_step(self, v) -> None:
|
||||||
# event loop processes the deferred delete.
|
self.guide_title.setText(v.guide.title)
|
||||||
w.hide()
|
while self.options.count():
|
||||||
w.setParent(None)
|
w = self.options.takeAt(0).widget()
|
||||||
w.deleteLater()
|
if w:
|
||||||
is_end = v.end is not None
|
# Detach now; deleteLater alone leaves it visible until the
|
||||||
self.step_text.setVisible(not is_end)
|
# event loop processes the deferred delete.
|
||||||
self.step_text.setText(_esc(v.text))
|
w.hide()
|
||||||
for opt_key, label in v.options:
|
w.setParent(None)
|
||||||
b = QPushButton(label)
|
w.deleteLater()
|
||||||
b.setMinimumHeight(34)
|
is_end = v.end is not None
|
||||||
if v.step and v.step.type == "led":
|
self.step_text.setVisible(not is_end)
|
||||||
b.setIcon(_swatch(opt_key))
|
self.step_text.setText(_esc(v.text))
|
||||||
b.setIconSize(QSize(22, 22))
|
show_qr = bool(v.step and v.step.show_qr)
|
||||||
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
if show_qr:
|
||||||
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
self.qr.set_payload(self._matter_payload())
|
||||||
self.options.addWidget(b)
|
self.qr.setVisible(show_qr)
|
||||||
step_type = v.step.type if v.step else None
|
for opt_key, label in v.options:
|
||||||
self.continue_btn.setVisible(step_type == "info")
|
b = QPushButton(label)
|
||||||
self.skip_btn.setVisible(step_type == "wait")
|
b.setMinimumHeight(34)
|
||||||
self.wait_bar.setVisible(step_type == "wait")
|
if v.step and v.step.type == "led":
|
||||||
self.wait_label.setVisible(step_type == "wait")
|
b.setIcon(_swatch(opt_key))
|
||||||
self.end_box.setVisible(is_end)
|
b.setIconSize(QSize(22, 22))
|
||||||
if is_end:
|
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
||||||
e = v.end
|
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
||||||
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
|
self.options.addWidget(b)
|
||||||
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
|
step_type = v.step.type if v.step else None
|
||||||
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
self.continue_btn.setVisible(step_type == "info")
|
||||||
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
|
self.skip_btn.setVisible(step_type == "wait")
|
||||||
+ (f"<ol>{acts}</ol>" if acts else ""))
|
self.wait_bar.setVisible(step_type == "wait")
|
||||||
|
self.wait_label.setVisible(step_type == "wait")
|
||||||
|
self.end_box.setVisible(is_end)
|
||||||
|
if is_end:
|
||||||
|
e = v.end
|
||||||
|
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
|
||||||
|
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
|
||||||
|
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
||||||
|
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
|
||||||
|
+ (f"<ol>{acts}</ol>" if acts else ""))
|
||||||
+111
-11
@@ -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,12 +30,19 @@ 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"}}
|
# "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
|
||||||
|
LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
|
||||||
|
"Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||||
COLORS = {
|
COLORS = {
|
||||||
"E": "#ff6b6b",
|
"E": "#ff6b6b",
|
||||||
"W": "#f5c451",
|
"W": "#f5c451",
|
||||||
@@ -42,6 +51,7 @@ COLORS = {
|
|||||||
LineKind.RESET: "#6fc3ff",
|
LineKind.RESET: "#6fc3ff",
|
||||||
LineKind.PANIC: "#ff4dd2",
|
LineKind.PANIC: "#ff4dd2",
|
||||||
LineKind.HOST: "#7ee07e",
|
LineKind.HOST: "#7ee07e",
|
||||||
|
LineKind.KOTA: "#5fd7e8",
|
||||||
LineKind.RAW: "#a8a8a8",
|
LineKind.RAW: "#a8a8a8",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +93,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 +109,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 +123,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 +151,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 +200,75 @@ 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 reported by this box (KOTA: matter), from its log, or the defaults."""
|
||||||
|
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [],
|
||||||
|
self._engine.matter_qr, self._engine.matter_manual)
|
||||||
|
|
||||||
|
# ---- 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)
|
||||||
@@ -222,19 +300,24 @@ class SerialTab(QWidget):
|
|||||||
self._counts[line.level] += 1
|
self._counts[line.level] += 1
|
||||||
self._update_status()
|
self._update_status()
|
||||||
|
|
||||||
|
if self._visible(line):
|
||||||
|
self._append(line)
|
||||||
|
|
||||||
|
def _visible(self, line: LogLine) -> bool:
|
||||||
allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
|
allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
|
||||||
if allowed is not None and line.kind != LineKind.HOST and not (
|
if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET):
|
||||||
line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET)
|
return True
|
||||||
):
|
if allowed == "KOTA":
|
||||||
return
|
return line.kind == LineKind.KOTA or line.level == "E"
|
||||||
self._append(line)
|
return line.level in allowed
|
||||||
|
|
||||||
def _append(self, line: LogLine) -> None:
|
def _append(self, line: LogLine) -> None:
|
||||||
sb = self.view.verticalScrollBar()
|
sb = self.view.verticalScrollBar()
|
||||||
at_bottom = sb.value() >= sb.maximum() - 4
|
at_bottom = sb.value() >= sb.maximum() - 4
|
||||||
fmt = QTextCharFormat()
|
fmt = QTextCharFormat()
|
||||||
fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
|
colour = COLORS.get(line.kind) if line.kind == LineKind.KOTA and line.level == "I" else None
|
||||||
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST):
|
fmt.setForeground(QColor(colour or COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
|
||||||
|
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST) or line.record in ("boot", "status"):
|
||||||
fmt.setFontWeight(QFont.Weight.Bold)
|
fmt.setFontWeight(QFont.Weight.Bold)
|
||||||
cur = QTextCursor(self.view.document())
|
cur = QTextCursor(self.view.document())
|
||||||
cur.movePosition(QTextCursor.MoveOperation.End)
|
cur.movePosition(QTextCursor.MoveOperation.End)
|
||||||
@@ -315,9 +398,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,76 @@
|
|||||||
|
"""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()}")
|
||||||
|
if payload.from_box:
|
||||||
|
src = "QR and manual code exactly as reported by the box (KOTA: matter line)"
|
||||||
|
else:
|
||||||
|
src = f"Discriminator {payload.discriminator} " + (
|
||||||
|
"(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"{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.8.0"
|
||||||
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
||||||
requires-python = ">=3.11,<3.14"
|
requires-python = ">=3.11,<3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"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
|
||||||
|
|||||||
+3
-1
@@ -2,8 +2,10 @@
|
|||||||
# Double-click in Finder, or run: bash run_macos.command
|
# Double-click in Finder, or run: bash run_macos.command
|
||||||
cd "$(dirname "$0")" || exit 1
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
|
# If Finder refuses to open this file (Google Drive drops the executable bit), run it once as:
|
||||||
|
# bash run_macos.command (or: chmod +x run_macos.command)
|
||||||
# Keep the virtual environment out of the (Google Drive) tool folder.
|
# Keep the virtual environment out of the (Google Drive) tool folder.
|
||||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.3"
|
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8"
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
if ! command -v uv >/dev/null 2>&1; then
|
if ! command -v uv >/dev/null 2>&1; then
|
||||||
|
|||||||
+1
-1
@@ -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.8"
|
||||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||||
|
|
||||||
where uv >nul 2>nul
|
where uv >nul 2>nul
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+22
@@ -0,0 +1,22 @@
|
|||||||
|
ESP-ROM:esp32s3-20210327
|
||||||
|
rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)
|
||||||
|
I (1299) app_init: Project name: kota-sensorboard
|
||||||
|
I (1303) app_init: App version: 1.0.16
|
||||||
|
I (1307) app_init: Compile time: Sep 28 2026 15:07:57
|
||||||
|
I (1317) app_init: ELF file SHA256: 4f10989e2...
|
||||||
|
E (1402) ID: [E-MAIN-04] Device MAC address: A4:CB:8F:C9:46:F8
|
||||||
|
I (1480) KOTA: led previous_boot status=CONNECTION_ERROR code=1
|
||||||
|
I (1481) KOTA: led color=purple pattern=blink period=1000ms
|
||||||
|
I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688
|
||||||
|
I (1795) KOTA: rtc running=1 time=2026-09-28T15:20:01
|
||||||
|
I (1850) SD_CARD: SD card mounted successfully
|
||||||
|
I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=0
|
||||||
|
I (2300) MAIN: ESP-Matter started successfully
|
||||||
|
I (2310) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET mode=matter commissioned=1 last_restart=none
|
||||||
|
I (2315) KOTA: matter fabrics=1 window=closed qr=MT:Y.K9042C00KA0648G00 manual=34970112332
|
||||||
|
I (2400) KOTA: mic enabled=1 (GPIO3=0, expected 0)
|
||||||
|
I (4481) KOTA: led color=green pattern=solid
|
||||||
|
I (5200) WIFI_SERVICE: Got IP address
|
||||||
|
I (6100) SNTP: Time synchronized: Mon Sep 28 15:20:06 2026
|
||||||
|
I (8100) KOTA: rtc verify running=1 delta=2s ok=1
|
||||||
|
I (60010) KOTA: health up=60s status=OK wifi=connected rssi=-58 time_valid=1 sd=ok buffer=0 heap=143220 psram=7.6M last_upload=14s ota=registered
|
||||||
+30
-6
@@ -43,16 +43,20 @@ APP = ["I (1299) app_init: Project name: kota-sensorboard",
|
|||||||
# ------------------------------------------------------------------ rules
|
# ------------------------------------------------------------------ rules
|
||||||
|
|
||||||
def test_rules_load_and_have_no_cycles():
|
def test_rules_load_and_have_no_cycles():
|
||||||
rules = load_rules()
|
from kota_tech.diag import kota
|
||||||
by_id = {r.id: r for r in rules}
|
graph = {r.id: r.explained_by for r in load_rules()}
|
||||||
for r in rules:
|
for st in ("ERROR", "OUT_OF_RESOURCES", "CONNECTION_ERROR", "RTC_ERROR", "SD_ERROR", "RESETING", "HTTP_SERVER"):
|
||||||
seen, todo = set(), list(r.explained_by)
|
for code in range(7):
|
||||||
|
if sf := kota.status_finding(st, code):
|
||||||
|
graph[sf[0]] = sf[5]
|
||||||
|
for start, explained_by in graph.items():
|
||||||
|
seen, todo = set(), list(explained_by)
|
||||||
while todo:
|
while todo:
|
||||||
rid = todo.pop()
|
rid = todo.pop()
|
||||||
assert rid != r.id, f"explained_by cycle through {r.id}"
|
assert rid != start, f"explained_by cycle through {start}"
|
||||||
if rid not in seen:
|
if rid not in seen:
|
||||||
seen.add(rid)
|
seen.add(rid)
|
||||||
todo += by_id[rid].explained_by
|
todo += graph.get(rid, ())
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------- real captures
|
# --------------------------------------------------------- real captures
|
||||||
@@ -233,3 +237,23 @@ def test_session_log_format(tmp_path):
|
|||||||
for ln in lines:
|
for ln in lines:
|
||||||
e.feed(ln)
|
e.feed(ln)
|
||||||
assert e.boot.tool_reset
|
assert e.boot.tool_reset
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_brownout_loop_is_a_power_problem():
|
||||||
|
# Real capture (2026-09-28): "E BOD: Brownout detector was triggered" has no
|
||||||
|
# timestamp and the ROM reports RTC_SW_SYS_RST, not a brown-out reset.
|
||||||
|
r = replay("live_brownout_loop_A4CB8FC946F8_fw1.0.txt")
|
||||||
|
top = r.roots[0]
|
||||||
|
assert top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5
|
||||||
|
assert "reboot.loop" in {c.rule_id for c in top.consequences}
|
||||||
|
assert r.verdict.startswith("Problem: Power supply")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("line", [
|
||||||
|
"E (2893) wifi:sta is connecting, return error",
|
||||||
|
"E (2892) chip[DL]: esp_wifi_connect() failed: ESP_ERR_WIFI_CONN",
|
||||||
|
"E (5120) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F",
|
||||||
|
])
|
||||||
|
def test_harmless_wifi_start_errors_are_not_reported(line):
|
||||||
|
_, r = run(BOOT + APP + [line])
|
||||||
|
assert r.roots == [] and "Unrecognised" not in r.to_text()
|
||||||
@@ -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,247 @@
|
|||||||
|
"""Flashing: image validation on the real image in firmware/, the folder
|
||||||
|
catalogue (compile-date order), 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, app_image_length, folder_images, inspect_image
|
||||||
|
from kota_tech.flash.plan import Mode, build_plan, check_partition_table
|
||||||
|
from kota_tech.flash.runner import FlashRunner
|
||||||
|
|
||||||
|
FW_DIR = Path(__file__).resolve().parents[1] / "firmware"
|
||||||
|
FW = sorted(FW_DIR.glob("*.bin"))[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def image():
|
||||||
|
imgs, problems = folder_images()
|
||||||
|
assert imgs and problems == []
|
||||||
|
return imgs[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ image
|
||||||
|
|
||||||
|
def test_folder_image_is_a_kota_build(image):
|
||||||
|
assert image.app.project == "kota-sensorboard"
|
||||||
|
assert image.kind == "merged"
|
||||||
|
assert image.app.built_at is not None # compile date is readable
|
||||||
|
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_label_starts_with_compile_date(image):
|
||||||
|
label = image.label()
|
||||||
|
assert label.startswith(f"Compiled {image.app.built_at:%Y-%m-%d %H:%M}")
|
||||||
|
assert image.app.version in label and image.name in label
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_integrity_check_on_folder_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)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- firmware folder
|
||||||
|
|
||||||
|
def _with_compile_date(data: bytes, date: str, time: str, off: int = 0x40000) -> bytes:
|
||||||
|
"""Copy of a merged image whose app claims another compile date, with the
|
||||||
|
segment checksum and appended SHA-256 recomputed (a valid, different build)."""
|
||||||
|
d = bytearray(data)
|
||||||
|
desc = off + 0x20
|
||||||
|
old = bytes(d[desc + 80:desc + 112])
|
||||||
|
new = time.encode().ljust(16, b"\0") + date.encode().ljust(16, b"\0")
|
||||||
|
d[desc + 80:desc + 112] = new
|
||||||
|
end = off + app_image_length(data, off) - 32 # SHA-256 follows the checksum byte
|
||||||
|
xor = 0
|
||||||
|
for a, b in zip(old, new):
|
||||||
|
xor ^= a ^ b
|
||||||
|
d[end - 1] ^= xor
|
||||||
|
d[end:end + 32] = hashlib.sha256(bytes(d[off:end])).digest()
|
||||||
|
return bytes(d)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_lists_builds_newest_compile_date_first(tmp_path):
|
||||||
|
data = FW.read_bytes()
|
||||||
|
(tmp_path / "a_old.bin").write_bytes(_with_compile_date(data, "Jan 5 2026", "08:00:00"))
|
||||||
|
(tmp_path / "z_new.bin").write_bytes(_with_compile_date(data, "Dec 24 2026", "18:30:00"))
|
||||||
|
(tmp_path / "m_mid.bin").write_bytes(_with_compile_date(data, "Jun 15 2026", "12:00:00"))
|
||||||
|
imgs, problems = folder_images(tmp_path)
|
||||||
|
assert problems == []
|
||||||
|
assert [i.name for i in imgs] == ["z_new.bin", "m_mid.bin", "a_old.bin"]
|
||||||
|
assert imgs[0].label().startswith("Compiled 2026-12-24 18:30")
|
||||||
|
assert imgs[2].label().startswith("Compiled 2026-01-05 08:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_reports_bad_files_and_duplicates(tmp_path):
|
||||||
|
data = FW.read_bytes()
|
||||||
|
(tmp_path / "good.bin").write_bytes(data)
|
||||||
|
(tmp_path / "copy.bin").write_bytes(data)
|
||||||
|
(tmp_path / "notes.bin").write_bytes(b"hello firmware team")
|
||||||
|
(tmp_path / "readme.txt").write_text("ignored: not a .bin")
|
||||||
|
imgs, problems = folder_images(tmp_path)
|
||||||
|
assert len(imgs) == 1
|
||||||
|
assert any("notes.bin" in p and "not an ESP32 firmware" in p for p in problems)
|
||||||
|
assert any("same file as" in p for p in problems)
|
||||||
|
assert not any("readme" in p for p in problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_only_file_uses_the_merged_layout(tmp_path, image):
|
||||||
|
(tmp_path / "merged.bin").write_bytes(image.data)
|
||||||
|
(tmp_path / "kota-sensorboard.bin").write_bytes(
|
||||||
|
_with_compile_date(image.data, "Dec 1 2026", "10:00:00")[0x40000:])
|
||||||
|
imgs, problems = folder_images(tmp_path)
|
||||||
|
assert problems == []
|
||||||
|
app = next(i for i in imgs if i.kind == "app")
|
||||||
|
assert imgs[0] is app # newer compile date first
|
||||||
|
assert app.partition("ota_0").offset == 0x40000 and "app only" in app.label()
|
||||||
|
assert [s.kind for s in build_plan(app, Mode.APP_ONLY, "COM5", tmp_path / "w").steps] == ["read_pt", "write"]
|
||||||
|
with pytest.raises(ValueError, match="app only"):
|
||||||
|
build_plan(app, Mode.FULL, "COM5", tmp_path / "w")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_only_file_without_merged_image_is_not_offered(tmp_path, image):
|
||||||
|
(tmp_path / "kota-sensorboard.bin").write_bytes(image.app_bytes())
|
||||||
|
imgs, problems = folder_images(tmp_path)
|
||||||
|
assert imgs == [] and "no complete (merged) image" in problems[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_or_missing_folder(tmp_path):
|
||||||
|
assert folder_images(tmp_path) == ([], [])
|
||||||
|
imgs, problems = folder_images(tmp_path / "nope")
|
||||||
|
assert imgs == [] and "not found" in problems[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- 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
|
||||||
+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,238 @@
|
|||||||
|
"""Firmware 1.0.16+ structured "KOTA:" lines and error IDs.
|
||||||
|
|
||||||
|
The lines below are built from the exact format strings in the firmware
|
||||||
|
source (boot_info.cpp, status.cpp, i2c.cpp, ds1307.cpp, sd_card.cpp,
|
||||||
|
main.cpp, microphone.cpp, health.cpp); no hardware capture exists yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kota_tech.core.logfile import read_log_file
|
||||||
|
from kota_tech.core.logline import LineKind, parse_line
|
||||||
|
from kota_tech.diag.engine import DiagnosticsEngine, Severity
|
||||||
|
from kota_tech.diag.firmware import same_build
|
||||||
|
from kota_tech.matter.payload import payload_from_log
|
||||||
|
from kota_tech.service.report import CONNECTIVITY, HARDWARE, service_status
|
||||||
|
|
||||||
|
FIX = Path(__file__).parent / "fixtures"
|
||||||
|
BANNER = ["ESP-ROM:esp32s3-20210327", "rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)"]
|
||||||
|
I2C_OK = "I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688"
|
||||||
|
SD_OK = ("I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB "
|
||||||
|
"env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=0")
|
||||||
|
BOOT = ("I (2310) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET "
|
||||||
|
"mode=matter commissioned=1 last_restart={last}")
|
||||||
|
|
||||||
|
|
||||||
|
def run(lines):
|
||||||
|
e = DiagnosticsEngine()
|
||||||
|
for i, text in enumerate(lines):
|
||||||
|
e.feed(parse_line(text, host_time=i * 0.01))
|
||||||
|
return e, e.report()
|
||||||
|
|
||||||
|
|
||||||
|
def roots(report):
|
||||||
|
return {rc.finding.rule_id: rc for rc in report.roots}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- parsing
|
||||||
|
|
||||||
|
def test_kota_lines_are_parsed_into_fields():
|
||||||
|
ln = parse_line(BOOT.format(last="status_watchdog"))
|
||||||
|
assert ln.kind == LineKind.KOTA and ln.record == "boot" and ln.level == "I"
|
||||||
|
assert ln.fields["fw"] == "1.0.16" and ln.fields["last_restart"] == "status_watchdog"
|
||||||
|
st = parse_line("I (5) KOTA: status OK -> CONNECTION_ERROR code=1 reason=influx_rejected")
|
||||||
|
assert st.words == ("OK", "->", "CONNECTION_ERROR") and st.fields == {"code": "1", "reason": "influx_rejected"}
|
||||||
|
mic = parse_line("I (6) KOTA: mic enabled=0 (GPIO3=1, expected 0)")
|
||||||
|
assert mic.fields["enabled"] == "0" and mic.fields["GPIO3"] == "1"
|
||||||
|
i2c = parse_line("I (7) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307(missing) 0x50=?")
|
||||||
|
assert i2c.fields["0x68"] == "DS1307(missing)" and i2c.fields["0x50"] == "?"
|
||||||
|
verify = parse_line("I (8) KOTA: rtc verify running=1 delta=2s ok=1")
|
||||||
|
assert verify.record == "rtc" and "verify" in verify.words and verify.fields["ok"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_id_is_split_off_but_kept_in_text():
|
||||||
|
ln = parse_line("E (1200) SD_CARD: [E-SD-03] Failed to initialize the card (ESP_ERR_TIMEOUT).")
|
||||||
|
assert ln.kind == LineKind.LOG and ln.error_id == "E-SD-03"
|
||||||
|
assert ln.message.startswith("Failed to initialize") and "[E-SD-03]" in ln.text
|
||||||
|
assert ln.match_text == "E (1200) SD_CARD: Failed to initialize the card (ESP_ERR_TIMEOUT)."
|
||||||
|
|
||||||
|
|
||||||
|
def test_sd_line_with_secret_key_names_is_not_redacted():
|
||||||
|
ln = parse_line("I (9) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB "
|
||||||
|
"env_keys=INFLUX_URL,INFLUX_TOKEN,SSID,PASSWORD buffer_lines=0")
|
||||||
|
assert ln.fields["env_keys"] == "INFLUX_URL,INFLUX_TOKEN,SSID,PASSWORD"
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- engine
|
||||||
|
|
||||||
|
def test_healthy_box_from_kota_lines():
|
||||||
|
e = DiagnosticsEngine()
|
||||||
|
for ln in read_log_file(FIX / "synthetic_fw1.0.16_healthy.txt"):
|
||||||
|
e.feed(ln)
|
||||||
|
r = e.report()
|
||||||
|
assert r.verdict.startswith("Healthy")
|
||||||
|
assert (r.led, r.led_pattern, r.led_exact, r.led_meaning) == ("GREEN", "solid", True, "OK")
|
||||||
|
assert "v1.0.16" in r.firmware.describe() and "4f10989e" in r.firmware.describe()
|
||||||
|
assert e.mac == "A4:CB:8F:C9:46:F8"
|
||||||
|
reached = {m for m, _, t in r.milestones if t is not None}
|
||||||
|
assert {"i2c_ok", "sd_mounted", "env_loaded", "matter_commissioned", "wifi_connected",
|
||||||
|
"time_synced", "clock_verified", "data_uploaded", "ota_registered"} <= reached
|
||||||
|
topics = {t: st for t, _, st in r.box}
|
||||||
|
assert topics["I2C"] == topics["SD card"] == topics["Health"] == "ok"
|
||||||
|
# The purple state before the tool's reset is shown, but only as information now.
|
||||||
|
prev = [f for f in r.earlier + [rc.finding for rc in r.roots] if f.rule_id == "boot.previous_status"]
|
||||||
|
assert not prev or prev[0].severity == Severity.INFO
|
||||||
|
assert "E-MAIN-04" not in r.to_text() # MAC line is a benign ESP_LOGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_i2c_bus_stuck_is_root_of_blue_led():
|
||||||
|
_, r = run(BANNER + [
|
||||||
|
"I (1480) KOTA: led color=blue pattern=pulses n=2",
|
||||||
|
"I (1790) KOTA: i2c SDA=0 SCL=1 bus=stuck",
|
||||||
|
"I (1791) KOTA: status OK -> RTC_ERROR code=2 reason=i2c_bus_stuck",
|
||||||
|
])
|
||||||
|
rs = roots(r)
|
||||||
|
assert "kota.i2c_stuck" in rs and rs["kota.i2c_stuck"].finding.severity == Severity.CRITICAL
|
||||||
|
assert "status.rtc.2" in {c.rule_id for c in rs["kota.i2c_stuck"].consequences}
|
||||||
|
assert (r.led, r.led_pattern) == ("BLUE", "2 pulses") and "I2C bus stuck" in r.led_meaning
|
||||||
|
assert service_status(r) == HARDWARE
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_sensor_named_by_i2c_scan_and_red_pulses():
|
||||||
|
_, r = run(BANNER + [
|
||||||
|
"I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66(missing) 0x76=BME688",
|
||||||
|
SD_OK,
|
||||||
|
"E (2100) MAIN: [E-MAIN-16] Failed to initialize SEN66: ESP_ERR_TIMEOUT",
|
||||||
|
"I (2101) KOTA: status OK -> ERROR code=2 reason=sen66_init_failed",
|
||||||
|
"I (2102) KOTA: led color=red pattern=pulses n=2",
|
||||||
|
])
|
||||||
|
rs = roots(r)
|
||||||
|
root = rs["kota.sensor_missing"]
|
||||||
|
assert root.finding.title == "SEN66 does not answer on the I2C bus"
|
||||||
|
grouped = {c.rule_id for c in root.consequences}
|
||||||
|
assert {"status.error.2", "sensor.init_failed"} <= grouped
|
||||||
|
init = next(c for c in root.consequences if c.rule_id == "sensor.init_failed")
|
||||||
|
assert "E-MAIN-16" in init.error_ids and "SEN66" in init.error_ids["E-MAIN-16"]
|
||||||
|
assert r.led_meaning == "SEN66 failed"
|
||||||
|
assert "missing: SEN66" in dict((t, x) for t, x, _ in r.box)["I2C"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_rejected_box_is_purple_blinking():
|
||||||
|
_, r = run(BANNER + [I2C_OK, SD_OK, BOOT.format(last="none"),
|
||||||
|
"I (70000) KOTA: status OK -> CONNECTION_ERROR code=1 reason=influx_rejected",
|
||||||
|
"I (70001) KOTA: led color=purple pattern=blink period=1000ms"])
|
||||||
|
assert r.roots[0].finding.rule_id == "status.conn.1"
|
||||||
|
assert r.roots[0].finding.severity == Severity.CRITICAL
|
||||||
|
assert (r.led, r.led_pattern) == ("PURPLE", "blinking (1000 ms)")
|
||||||
|
assert "server rejected" in r.led_meaning
|
||||||
|
assert "influx_rejected" in r.led_reason
|
||||||
|
assert service_status(r) == CONNECTIVITY
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_back_to_ok_resolves_the_status_finding():
|
||||||
|
_, r = run(BANNER + [I2C_OK, "I (5000) KOTA: status OK -> CONNECTION_ERROR reason=influx_failed",
|
||||||
|
"I (9000) KOTA: status CONNECTION_ERROR -> OK reason=influx_ok"])
|
||||||
|
assert "status.conn" not in roots(r)
|
||||||
|
assert any(f.rule_id == "status.conn" and f.resolved_by == "status -> OK" for f in r.resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_reason_from_the_next_boot_line():
|
||||||
|
_, r = run(BANNER + [I2C_OK, BOOT.format(last="none"),
|
||||||
|
"I (5000) KOTA: status OK -> ERROR code=3 reason=as7341_failed",
|
||||||
|
"W (155000) KOTA: restart reason=status_watchdog",
|
||||||
|
"I (155001) KOTA: led restart_flash color=red",
|
||||||
|
"ESP-ROM:esp32s3-20210327", "rst:0xc (RTC_SW_CPU_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)",
|
||||||
|
"I (1480) KOTA: led previous_boot status=ERROR code=3",
|
||||||
|
I2C_OK, BOOT.format(last="status_watchdog")])
|
||||||
|
ids = {f.rule_id: f for rc in r.roots for f in [rc.finding, *rc.consequences]}
|
||||||
|
f = ids["reboot.status_watchdog"]
|
||||||
|
assert "150 s" in f.title and "state ERROR code=3" in f.meaning
|
||||||
|
|
||||||
|
|
||||||
|
def test_brownout_restart_recorded_by_firmware():
|
||||||
|
_, r = run(BANNER + [I2C_OK, BOOT.format(last="none"), "I (90000) KOTA: health up=90s status=OK "
|
||||||
|
"wifi=connected rssi=-60 time_valid=1 sd=ok buffer=0 heap=150000 psram=7.6M "
|
||||||
|
"last_upload=10s ota=registered",
|
||||||
|
"ESP-ROM:esp32s3-20210327", "rst:0xf (RTCWDT_BROWN_OUT_RESET),boot:0x8",
|
||||||
|
I2C_OK, BOOT.format(last="brownout")])
|
||||||
|
assert r.roots[0].finding.rule_id == "reboot.brownout"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncaptured_error_id_uses_the_csv_action():
|
||||||
|
_, r = run(BANNER + [I2C_OK, "E (3000) INFLUX_BUFFER: [E-INF-13] Failed to open buffer file for appending"])
|
||||||
|
f = next(f for rc in r.roots for f in [rc.finding, *rc.consequences] if "E-INF-13" in f.error_ids)
|
||||||
|
# A rule already knows this message, so the rule finding carries the code.
|
||||||
|
assert f.rule_id == "sd.write_fail"
|
||||||
|
_, r = run(BANNER + [I2C_OK, "E (3000) SPI: [E-SPI-01] Failed to take SPI mutex"])
|
||||||
|
f = roots(r)["eid.SPI"].finding
|
||||||
|
assert f.title == "SPI bus: Failed to take SPI mutex"
|
||||||
|
assert "firmware team" in f.actions[0] and f.error_ids == {"E-SPI-01": f.actions[0]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_rules_still_match_lines_with_error_ids():
|
||||||
|
_, r = run(BANNER + ["E (1200) MAIN: [E-MAIN-13] Failed to initialize RTC: ESP_ERR_TIMEOUT"])
|
||||||
|
assert "rtc.init_failed" in roots(r)
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_line_gives_status_when_connected_mid_run():
|
||||||
|
e, r = run(["I (600000) KOTA: health up=600s status=CONNECTION_ERROR wifi=disconnected rssi=0 "
|
||||||
|
"time_valid=1 sd=ok buffer=120 heap=150000 psram=7.6M last_upload=never ota=registered",
|
||||||
|
"I (660000) KOTA: health up=660s status=CONNECTION_ERROR wifi=disconnected rssi=0 "
|
||||||
|
"time_valid=1 sd=ok buffer=130 heap=150000 psram=7.6M last_upload=never ota=registered"])
|
||||||
|
assert (r.led, r.led_pattern) == ("PURPLE", "solid") and not r.led_exact
|
||||||
|
rs = roots(r)
|
||||||
|
assert "kota.wifi_down" in rs
|
||||||
|
assert "status.conn" in {c.rule_id for c in rs["kota.wifi_down"].consequences}
|
||||||
|
assert r.mid_run
|
||||||
|
|
||||||
|
|
||||||
|
def test_sd_and_rtc_self_report():
|
||||||
|
_, r = run(BANNER + [
|
||||||
|
"I (1795) KOTA: rtc running=0 time=2000-01-01T00:00:00",
|
||||||
|
"I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL buffer_lines=0",
|
||||||
|
"I (8100) KOTA: rtc verify running=0 delta=0s ok=0",
|
||||||
|
])
|
||||||
|
ids = {f.key for rc in r.roots for f in [rc.finding, *rc.consequences]}
|
||||||
|
assert {"influx.env_missing:INFLUX_TOKEN", "kota.rtc_stopped", "kota.rtc_not_ticking"} <= ids
|
||||||
|
_, r = run(BANNER + ["I (1990) KOTA: sd mounted=0 err=ESP_ERR_TIMEOUT",
|
||||||
|
"I (1991) KOTA: status OK -> SD_ERROR reason=sd_mount_failed"])
|
||||||
|
rs = roots(r)
|
||||||
|
assert "kota.sd_unmounted" in rs and "ESP_ERR_TIMEOUT" in rs["kota.sd_unmounted"].finding.title
|
||||||
|
assert "status.sd" in {c.rule_id for c in rs["kota.sd_unmounted"].consequences}
|
||||||
|
|
||||||
|
|
||||||
|
def test_matter_and_mic_lines():
|
||||||
|
e, r = run(BANNER + ["I (2315) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332",
|
||||||
|
"I (2400) KOTA: mic enabled=0 (GPIO3=1, expected 0)",
|
||||||
|
"I (4481) KOTA: led color=cyan pattern=breathe period=3000ms"])
|
||||||
|
rs = roots(r)
|
||||||
|
assert "matter.not_commissioned" in rs and "mic.disabled" in rs
|
||||||
|
assert (r.led, r.led_meaning) == ("CYAN", "waiting to be paired in Matter")
|
||||||
|
p = payload_from_log([], e.matter_qr, e.matter_manual)
|
||||||
|
assert p.qr_text() == "MT:Y.K9042C00KA0648G00" and p.manual_code_pretty() == "3497-011-2332"
|
||||||
|
_, r = run(BANNER + ["I (2315) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332",
|
||||||
|
"I (90000) KOTA: matter fabrics=1 window=closed qr=MT:Y.K9042C00KA0648G00 manual=34970112332"])
|
||||||
|
assert "matter.not_commissioned" not in roots(r)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_prefixes_of_different_length_match():
|
||||||
|
full = "4f10989e2b40c1d29fb3e8f8faaa50e5555997909c6d6b1db9d4e1e4d04555dc"
|
||||||
|
assert same_build("4f10989e", full) and same_build(full, "4f10989e2")
|
||||||
|
assert not same_build("4f10989f", full) and not same_build(None, full)
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_fw1016_uncommissioned_box():
|
||||||
|
# Real capture (2026-09-28) after flashing 1.0.16 App-only: not paired in Matter.
|
||||||
|
e = DiagnosticsEngine()
|
||||||
|
for ln in read_log_file(FIX / "live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt"):
|
||||||
|
e.feed(ln)
|
||||||
|
r = e.report()
|
||||||
|
assert r.firmware.version == "1.0.16" and same_build(r.firmware.elf_sha, "4f10989e")
|
||||||
|
assert (r.led, r.led_pattern, r.led_exact) == ("CYAN", "breathing", True)
|
||||||
|
rs = roots(r)
|
||||||
|
assert r.roots[0].finding.rule_id == "matter.not_commissioned"
|
||||||
|
grouped = {c.rule_id for c in rs["matter.not_commissioned"].consequences}
|
||||||
|
assert {"kota.wifi_down", "net.dns_fail", "status.conn", "kota.buffer_backlog"} <= grouped
|
||||||
|
assert "rtc.clock_not_running" in {c.rule_id for c in rs["kota.rtc_stopped"].consequences}
|
||||||
|
assert e.matter_qr == "MT:Y.K9042C00KA0648G00"
|
||||||
|
assert not any(f.rule_id.startswith("other.") for f in e.findings.values())
|
||||||
@@ -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
|
||||||
@@ -12,3 +12,15 @@ def test_other_ports_not_sensorbox():
|
|||||||
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
|
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
|
||||||
assert not bt.is_sensorbox and bt.mac is None
|
assert not bt.is_sensorbox and bt.mac is None
|
||||||
assert bt.label == "COM3 - Standard Serial over Bluetooth link"
|
assert bt.label == "COM3 - Standard Serial over Bluetooth link"
|
||||||
|
|
||||||
|
|
||||||
|
def test_macos_lists_only_callout_devices(monkeypatch):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from kota_tech.serialio import ports
|
||||||
|
fake = [SimpleNamespace(device=d, description="USB JTAG/serial debug unit", vid=0x303A, pid=0x1001,
|
||||||
|
serial_number="A4:CB:8F:C9:46:F8")
|
||||||
|
for d in ("/dev/tty.usbmodem1101", "/dev/cu.usbmodem1101")]
|
||||||
|
monkeypatch.setattr(ports.sys, "platform", "darwin")
|
||||||
|
monkeypatch.setattr(ports.list_ports, "comports", lambda: fake)
|
||||||
|
assert [p.device for p in ports.list_serial_ports()] == ["/dev/cu.usbmodem1101"]
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""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")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fs", ["FAT32", "DOS_FAT_32", "Windows_FAT_32", "MS-DOS FAT32"])
|
||||||
|
def test_fat32_names_from_windows_and_macos_are_accepted(tmp_path, fs):
|
||||||
|
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
|
||||||
|
assert not any("File system" in p for p in check_card(str(tmp_path), fs).problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exfat_is_flagged(tmp_path):
|
||||||
|
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
|
||||||
|
assert any("File system" in p for p in check_card(str(tmp_path), "ExFAT").problems)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""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 "↳ [Error] 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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_mirrors_the_diagnosis_panel():
|
||||||
|
from pathlib import Path
|
||||||
|
from kota_tech.core.logfile import read_log_file
|
||||||
|
e = DiagnosticsEngine()
|
||||||
|
for ln in read_log_file(Path(__file__).parent / "fixtures" / "live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt"):
|
||||||
|
e.feed(ln)
|
||||||
|
note = service_note(e.report())
|
||||||
|
assert len(note) <= 5000
|
||||||
|
for part in ("Status LED should be: Cyan, breathing", "Box self-report (KOTA lines):",
|
||||||
|
"✔ I2C: found: AS7341, DS1307, SEN66, BME688", "Start-up progress (current boot):",
|
||||||
|
"✔ SD card mounted (1.7 s)", "○ Connected to Wi-Fi", "What to do:",
|
||||||
|
"Seen 12x, first at 21.6 s after start, last at 126.8 s",
|
||||||
|
"↳ [Error] No Internet: server names cannot be resolved"):
|
||||||
|
assert part in note, part
|
||||||
@@ -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.8.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