Compare commits
15
Commits
v0.3
..
6a62beb6aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a62beb6aa | ||
|
|
5bd14e1d5b | ||
|
|
ab420220e1 | ||
|
|
0d21c5c205 | ||
|
|
3fe45fa4d5 | ||
|
|
1ab8af8fe2 | ||
|
|
bfbbd537d5 | ||
|
|
a33736742a | ||
|
|
724c2d62f9 | ||
|
|
43f4c51aa4 | ||
|
|
bfa7edce6f | ||
|
|
dd915b5a74 | ||
|
|
22bd853ecf | ||
|
|
f5b32e1554 | ||
|
|
72adfe1cec |
No files matched your search
@@ -1,4 +1,6 @@
|
||||
* text=auto eol=lf
|
||||
*.command text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.bin binary
|
||||
*.png binary
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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).
|
||||
|
||||
## Dla technika: dwa pliki
|
||||
|
||||
| System | Aktualizacja | Uruchomienie |
|
||||
|---|---|---|
|
||||
| Windows | `update_windows.bat` | `run_windows.bat` |
|
||||
| macOS | `update_macos.command` | `run_macos.command` |
|
||||
|
||||
- **Aktualizacja** sprawdza na Gitea najnowszą wydaną wersję i pobiera tylko to, co się zmieniło
|
||||
(pliki programu, firmware i potrzebne biblioteki). Uruchom ją, gdy firmware team ogłosi nową wersję,
|
||||
albo po prostu przed wyjazdem do klienta. Pokazuje, co się zmieniło.
|
||||
- **Uruchomienie** startuje program. Nie łączy się z Gitea, działa też bez internetu (po pierwszej aktualizacji).
|
||||
|
||||
Nie trzymaj folderu narzędzia w Google Drive ani OneDrive.
|
||||
|
||||
## Pierwsza instalacja (raz na komputer)
|
||||
|
||||
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` (macOS: `~/Documents/KOTA/technician-tool`).
|
||||
3. Uruchom plik aktualizacji dla swojego systemu. Za pierwszym razem:
|
||||
- połączy folder z Gitea i zapyta o login do Gitea (hasło lub token); system je zapamięta,
|
||||
- zainstaluje brakujące narzędzia za Twoją zgodą: Git (Windows: przez winget; macOS: Apple Command Line Tools
|
||||
- po ich instalacji uruchom aktualizację jeszcze raz) oraz `uv` (menedżer Pythona, tylko dla bieżącego
|
||||
użytkownika),
|
||||
- pobierze biblioteki Pythona (1–2 minuty, potrzebny internet).
|
||||
4. Uruchom program plikiem `run_...`.
|
||||
|
||||
**macOS:** jeśli Finder odpowie, że pliku nie można uruchomić (zdarza się po rozpakowaniu zip), otwórz Terminal,
|
||||
wpisz `bash ` (ze spacją), przeciągnij `update_macos.command` do okna i naciśnij Enter. Po pierwszej aktualizacji
|
||||
oba pliki `.command` dają się już otwierać dwuklikiem.
|
||||
|
||||
Zamiast zip można też zrobić `git clone https://gitea.kota-app.com/kkallas/kota-technician-tool.git` i dalej
|
||||
używać tych samych dwóch plików.
|
||||
|
||||
Konkretna starsza wersja (np. do serwisu nietypowego boxa): w folderze narzędzia `git switch --detach v0.8`;
|
||||
kolejna aktualizacja wraca do najnowszej.
|
||||
|
||||
Wszystkie wersje używają jednego środowiska Pythona poza folderem narzędzia:
|
||||
`%LOCALAPPDATA%\KotaTechnicianTool\venv` (Windows), `~/Library/Caches/KotaTechnicianTool/venv` (macOS).
|
||||
Stare środowiska `venv-0.x` z poprzednich wersji można usunąć.
|
||||
|
||||
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.
|
||||
|
||||
## Wi-Fi przez USB (bez otwierania obudowy)
|
||||
|
||||
Zakładka **Wi-Fi (USB)**: podłącz box kablem USB, kliknij **Connect to the box**, wpisz nazwę sieci i hasło,
|
||||
kliknij **Write to the box and connect**. Box zapisuje je na karcie SD, restartuje się i łączy z siecią
|
||||
(zamiast Matter); zakładka pokazuje każdy krok aż do „Connected ... (signal -58 dBm)”.
|
||||
**Read current Wi-Fi settings** pokazuje zapisaną sieć (bez hasła), **Remove Wi-Fi** wraca do Matter.
|
||||
Wymaga firmware z komendami USB (plik `firmware/merged-binary-v13.bin` lub nowszy); starszy firmware
|
||||
wgraj w zakładce **Flash firmware** (tryb App only zachowuje parowanie i ustawienia).
|
||||
|
||||
## 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 `uv.lock`, tag `vX.Y`
|
||||
(`git tag -a vX.Y -m "Technician tool vX.Y"`, `git push origin vX.Y`), opis w **Releases** na Gitea.
|
||||
Aktualizacja techników bierze **tylko tagi `v*`** – zmiany w `main` bez tagu do nich nie trafiają.
|
||||
- Nie commitujemy `ENV.TXT` z prawdziwym tokenem ani haseł Wi-Fi.
|
||||
- Testy:
|
||||
|
||||
```
|
||||
uv run --frozen pytest -q
|
||||
```
|
||||
|
||||
Zmiany w wersjach: `docs/CHANGES-v0.*.md`; historia v0.1–v0.8 jest w tagach gita.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Technician tool v0.10 - changes (draft)
|
||||
|
||||
- New tab **Wi-Fi (USB)**: write the Wi-Fi name and password to the box's SD card over the USB cable, without
|
||||
opening the case (firmware usb_console, `kota wifi set`). Shows each step: command support check, stored,
|
||||
restart in Wi-Fi mode, connected with signal strength (polls `kota health`). Also reads the stored network
|
||||
(`kota wifi get`, password never shown) and removes it (`kota wifi clear`, back to Matter).
|
||||
- The password is sent hex-encoded, never written to the session log or the service report.
|
||||
- Diagnosis: restarts `usb_wifi_set` / `usb_wifi_clear` are intentional (no "unexpected restart"); box
|
||||
self-report shows the stored Wi-Fi network; `cmd health` replies update the health line.
|
||||
- firmware/: `merged-binary-v13.bin` (usb_console, build 3ae273ec) replaces `merged-binary.bin` (build 4f10989e,
|
||||
still in git tag v0.9). Note: v13 reports the same version 1.0.16 and compile date as the previous build;
|
||||
the firmware team should bump the version for the next build.
|
||||
- error_ids.csv updated from the firmware (E-CON-01).
|
||||
- Test that builds the main window (catches start-up errors).
|
||||
@@ -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,9 @@
|
||||
# Technician tool v0.9 - changes
|
||||
|
||||
- Two files per system for technicians: `update_windows.bat` / `update_macos.command` (update) and
|
||||
`run_windows.bat` / `run_macos.command` (start).
|
||||
- Update: checks Gitea for the newest release tag `vX.Y`, downloads only what changed (git), shows the list of
|
||||
changes, protects files edited by hand (asks first), connects a folder unpacked from a release zip on first
|
||||
run, installs git / uv when missing (asks first) and brings the Python libraries up to date.
|
||||
- One shared Python environment for all versions: `KotaTechnicianTool/venv` (was `venv-0.x` per version).
|
||||
- A developer copy on a branch is left alone by the update script (use `git pull`).
|
||||
@@ -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
|
||||
- pyserial>=3.5
|
||||
- pyside6>=6.7
|
||||
- esptool>=4.8,<5
|
||||
- segno>=1.6
|
||||
- pytest>=8
|
||||
Binary file not shown.
@@ -1,3 +1,3 @@
|
||||
"""KOTA sensorbox technician tool."""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.9.0"
|
||||
@@ -4,6 +4,12 @@ ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally
|
||||
wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset
|
||||
reason) has no level prefix and is classified separately, since a boot
|
||||
banner is the only reliable reboot marker.
|
||||
|
||||
Firmware 1.0.16+ adds structured diagnostic lines logged under the "KOTA"
|
||||
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] ").
|
||||
Both are parsed here so the diagnostics engine reads fields directly
|
||||
instead of matching message text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,6 +27,9 @@ ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
|
||||
# "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_]+)\)")
|
||||
# "[E-SD-03] Failed to initialize the card ..." (firmware error_ids.csv).
|
||||
ERROR_ID_RE = re.compile(r"^\[(E-[A-Z0-9]+-\d{2,})\] ?")
|
||||
KOTA_TAG = "KOTA"
|
||||
PANIC_RE = re.compile(
|
||||
r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|"
|
||||
r"^Backtrace:|Rebooting\.\.\.$"
|
||||
@@ -32,6 +41,7 @@ class LineKind(str, Enum):
|
||||
BOOT = "boot" # ROM boot banner (start of a new boot)
|
||||
RESET = "reset" # ROM reset-reason line
|
||||
PANIC = "panic" # crash / abort output
|
||||
KOTA = "kota" # structured "KOTA: <record> key=value ..." line
|
||||
RAW = "raw" # anything else (task list rows, line protocol, ...)
|
||||
HOST = "host" # event generated by this tool (connect, reset, ...)
|
||||
|
||||
@@ -50,6 +60,44 @@ class LogLine:
|
||||
tag: str | None = None
|
||||
message: str | None = None
|
||||
reset_reason: str | None = None
|
||||
error_id: str | None = None # "E-SD-03" when the message carries one
|
||||
record: str | None = None # KOTA lines: "boot", "i2c", "health", ...
|
||||
fields: dict[str, str] = field(default_factory=dict) # KOTA key=value pairs
|
||||
words: tuple[str, ...] = () # KOTA bare words ("verify", "OK", "->", ...)
|
||||
|
||||
@property
|
||||
def match_text(self) -> str:
|
||||
"""Text for message rules: the error-ID prefix removed, so rules
|
||||
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)
|
||||
return self.text
|
||||
|
||||
|
||||
def parse_kota(body: str) -> tuple[str, dict[str, str], tuple[str, ...]]:
|
||||
"""'rtc verify running=1 delta=2s ok=1' -> ('rtc', {...}, ('verify',)).
|
||||
|
||||
Values never contain spaces. The mic line has a parenthesised aside
|
||||
("(GPIO3=1, expected 1)"): brackets and commas are stripped from keys
|
||||
and values, and the aside's words are kept as bare words.
|
||||
"""
|
||||
tokens = body.split()
|
||||
if not tokens:
|
||||
return "", {}, ()
|
||||
fields: dict[str, str] = {}
|
||||
words: list[str] = []
|
||||
for tok in tokens[1:]:
|
||||
key, eq, value = tok.partition("=")
|
||||
if eq and key.strip("(,"):
|
||||
key = key.lstrip("(")
|
||||
# "0x68=DS1307(missing)" keeps its bracket; "(GPIO3=1," loses it.
|
||||
value = value.rstrip(",")
|
||||
if tok.startswith("(") or value.endswith(")") and "(" not in value:
|
||||
value = value.rstrip(")")
|
||||
fields[key] = value
|
||||
else:
|
||||
words.append(tok.strip("(),"))
|
||||
return tokens[0], fields, tuple(w for w in words if w)
|
||||
|
||||
|
||||
def clean(raw: str) -> str:
|
||||
@@ -61,7 +109,15 @@ def parse_line(raw: str, host_time: float | None = None) -> LogLine:
|
||||
t = time.time() if host_time is None else host_time
|
||||
if m := ESP_LOG_RE.match(text):
|
||||
level, ms, tag, msg = m.groups()
|
||||
return LogLine(text, LineKind.LOG, t, level, int(ms), tag.strip(), msg)
|
||||
tag = tag.strip()
|
||||
if tag == KOTA_TAG:
|
||||
record, fields, words = parse_kota(msg)
|
||||
return LogLine(text, LineKind.KOTA, t, level, int(ms), tag, msg,
|
||||
record=record, fields=fields, words=words)
|
||||
eid = None
|
||||
if em := ERROR_ID_RE.match(msg):
|
||||
eid, msg = em.group(1), msg[em.end():]
|
||||
return LogLine(text, LineKind.LOG, t, level, int(ms), tag, msg, error_id=eid)
|
||||
if BOOT_BANNER_RE.match(text):
|
||||
return LogLine(text, LineKind.BOOT, t)
|
||||
if m := RESET_REASON_RE.match(text):
|
||||
|
||||
+469
-24
@@ -6,7 +6,11 @@ line cannot express:
|
||||
was missed) and classification of why the previous boot ended;
|
||||
* the status-LED model (Status::set_status / set_status_conditional);
|
||||
* crash capture (panic / ESP_ERROR_CHECK output);
|
||||
* root-cause grouping via `explained_by` chains.
|
||||
* root-cause grouping via `explained_by` chains;
|
||||
* the structured "KOTA:" lines of firmware 1.0.16+ (diag/kota.py): exact
|
||||
status/LED, restart reasons, I2C/SD/RTC/Matter/mic self-report and the
|
||||
60 s health line, plus error IDs ("[E-SD-03]") looked up in
|
||||
error_ids.csv. Older firmware is still diagnosed from message text.
|
||||
|
||||
Findings are session-wide (a reboot loop must keep its cause visible);
|
||||
milestones and the LED are per boot.
|
||||
@@ -22,9 +26,11 @@ from enum import IntEnum
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.logline import LineKind, LogLine
|
||||
from . import kota
|
||||
from .firmware import FirmwareInfo
|
||||
|
||||
RULES_PATH = Path(__file__).with_name("rules.toml")
|
||||
_MAC_LINE = re.compile(r"Device MAC address: ([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})")
|
||||
|
||||
|
||||
class Severity(IntEnum):
|
||||
@@ -47,6 +53,7 @@ LED = {
|
||||
"PURPLE": ("Purple", "#8e44ad"),
|
||||
"RED": ("Red", "#d64541"),
|
||||
"BLUE": ("Blue", "#2f6fd6"),
|
||||
"CYAN": ("Cyan", "#1fc8d8"),
|
||||
"YELLOW": ("Yellow", "#e6c229"),
|
||||
"ORANGE": ("Orange", "#f08c1a"),
|
||||
"WHITE": ("White", "#f4f4f4"),
|
||||
@@ -61,9 +68,11 @@ MILESTONES = [
|
||||
("matter_started", "Matter started", True),
|
||||
("matter_commissioned", "Paired in Matter", True),
|
||||
("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True),
|
||||
("i2c_ok", "All sensors answer on the I2C bus", True),
|
||||
("sensors_ready", "All sensors started", False),
|
||||
("wifi_connected", "Connected to Wi-Fi", False),
|
||||
("time_synced", "Clock set from the Internet", False),
|
||||
("clock_verified", "Clock keeps time (RTC verified)", True),
|
||||
("data_uploaded", "Data uploaded to InfluxDB", False),
|
||||
("ota_registered", "Registered for firmware updates", False),
|
||||
("ota_checked", "Checked for firmware updates", False),
|
||||
@@ -76,11 +85,15 @@ MILESTONES = [
|
||||
_INIT_FAILURES = [
|
||||
"rtc.init_failed", "sensor.init_failed", "sd.card_fail", "sd.mount_fail",
|
||||
"sd.env_open_fail", "influx.env_missing", "influx.init_fail",
|
||||
"kota.i2c_stuck", "kota.sensor_missing", "kota.rtc_read_failed", "kota.sd_unmounted",
|
||||
]
|
||||
_NETWORK_ROOTS = [
|
||||
"influx.buffer_fail", "ota.check_failed", "net.dns_fail",
|
||||
"matter.not_commissioned", "wifi.disconnected",
|
||||
]
|
||||
# Printed by the brown-out handler without a timestamp ("E BOD: ..."); on the
|
||||
# ESP32-S3 the ROM then reports RTC_SW_SYS_RST, not a brown-out reset.
|
||||
_BROWNOUT = "Brownout detector was triggered"
|
||||
_RESET_BY_TOOL_WINDOW_S = 5.0
|
||||
_SHORT_BOOT_MS = 60_000
|
||||
|
||||
@@ -97,7 +110,7 @@ def _fmt(s: str, groups: dict[str, str]) -> str:
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
id: str
|
||||
pattern: re.Pattern[str]
|
||||
pattern: re.Pattern[str] | None # None: raised from KOTA: fields
|
||||
severity: Severity
|
||||
title: str = ""
|
||||
meaning: str = ""
|
||||
@@ -118,7 +131,7 @@ def load_rules(path: Path = RULES_PATH) -> list[Rule]:
|
||||
for r in data["rule"]:
|
||||
rules.append(Rule(
|
||||
id=r["id"],
|
||||
pattern=re.compile(r["pattern"]),
|
||||
pattern=re.compile(r["pattern"]) if "pattern" in r else None,
|
||||
severity=Severity[r["severity"].upper()],
|
||||
title=r.get("title", ""),
|
||||
meaning=r.get("meaning", ""),
|
||||
@@ -144,7 +157,8 @@ def _validate(rules: list[Rule]) -> None:
|
||||
known = set(ids)
|
||||
for r in rules:
|
||||
for ref in (*r.explained_by, *r.clears):
|
||||
if ref not in known:
|
||||
# status.* / reboot.* findings are created by the engine itself.
|
||||
if ref not in known and not ref.startswith(("status.", "reboot.")):
|
||||
raise ValueError(f"{r.id}: unknown rule id {ref!r}")
|
||||
for colour in (r.led, r.led_from):
|
||||
if colour and colour not in LED:
|
||||
@@ -171,11 +185,25 @@ class Finding:
|
||||
resolved: bool = False
|
||||
resolved_by: str | None = None
|
||||
evidence: deque[str] = field(default_factory=lambda: deque(maxlen=3))
|
||||
error_ids: dict[str, str] = field(default_factory=dict) # "E-SD-03" -> technician action
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.count >= self.min_count and not self.resolved
|
||||
|
||||
def seen_text(self) -> str:
|
||||
"""'Seen 3x, first at 2.9 s after start, last at 61.2 s' (panel and service note)."""
|
||||
s = f"Seen {self.count}x"
|
||||
if self.first_uptime_ms is not None:
|
||||
first = f"{self.first_uptime_ms / 1000:.1f}"
|
||||
s += f", first at {first} s after start"
|
||||
last = f"{self.last_uptime_ms / 1000:.1f}" if self.last_uptime_ms is not None else first
|
||||
if self.count > 1 and last != first:
|
||||
s += f", last at {last} s"
|
||||
if self.resolved and self.resolved_by:
|
||||
s += f" - resolved ({self.resolved_by})"
|
||||
return s
|
||||
|
||||
|
||||
@dataclass
|
||||
class BootRecord:
|
||||
@@ -188,8 +216,15 @@ class BootRecord:
|
||||
saw_log: bool = False
|
||||
led_at_end: str | None = None
|
||||
panic_lines: list[str] = field(default_factory=list)
|
||||
brownout: bool = False # "E BOD: Brownout detector was triggered" before the restart
|
||||
recent_errors: deque[str] = field(default_factory=lambda: deque(maxlen=5))
|
||||
intentional_restart: bool = False # factory reset / setup-mode toggle
|
||||
# KOTA: lines (firmware 1.0.16+)
|
||||
kota: dict[str, LogLine] = field(default_factory=dict) # last line per record
|
||||
last_restart: str | None = None # "boot ... last_restart=" (why the previous boot ended)
|
||||
restart_reason: str | None = None # "restart reason=" printed just before this boot ended
|
||||
previous_status: tuple[str, int] | None = None # "led previous_boot status= code="
|
||||
mode: str | None = None # matter | wifi | provisioning
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -217,14 +252,27 @@ class Report:
|
||||
lines: int
|
||||
mid_run: bool # no boot seen yet
|
||||
technician: list[str] = field(default_factory=list) # guided-check answers
|
||||
led_pattern: str = "" # "2 pulses", "solid", ... (exact when from a KOTA led line)
|
||||
led_meaning: str = ""
|
||||
led_exact: bool = False # reported by the box itself
|
||||
box: list[tuple[str, str, str]] = field(default_factory=list) # (topic, text, ok|warn|bad|info)
|
||||
matter_qr: str | None = None
|
||||
matter_manual: str | None = None
|
||||
|
||||
def led_text(self) -> str:
|
||||
t = LED[self.led][0] + (f", {self.led_pattern}" if self.led_pattern else "")
|
||||
return t + (f" = {self.led_meaning}" if self.led_meaning else "")
|
||||
|
||||
def to_text(self) -> str:
|
||||
out = [f"VERDICT: {self.verdict}",
|
||||
f"Expected status LED: {LED[self.led][0]}"
|
||||
f"Expected status LED: {self.led_text()}"
|
||||
+ (f" (last change: {self.led_reason})" if self.led_reason else ""),
|
||||
f"Firmware: {self.firmware.describe()}",
|
||||
f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}",
|
||||
"", "Milestones (current boot):"]
|
||||
f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}"]
|
||||
if self.box:
|
||||
out += ["", "Box self-report (KOTA lines):"]
|
||||
out += [f" {topic}: {text}" for topic, text, _ in self.box]
|
||||
out += ["", "Milestones (current boot):"]
|
||||
for _, label, t in self.milestones:
|
||||
out.append(f" [{'x' if t is not None else ' '}] {label}"
|
||||
+ (f" @ {t / 1000:.1f} s" if t is not None else ""))
|
||||
@@ -255,6 +303,8 @@ class Report:
|
||||
def _finding_text(f: Finding, ind: str) -> list[str]:
|
||||
lines = [f"{ind}- [{f.severity.label}] {f.title} (x{f.count})", f"{ind} {f.meaning}"]
|
||||
lines += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)]
|
||||
for eid, action in f.error_ids.items():
|
||||
lines.append(f"{ind} error {eid}" + (f": {action}" if action else ""))
|
||||
if f.evidence:
|
||||
lines.append(f"{ind} log: {f.evidence[-1]}")
|
||||
return lines
|
||||
@@ -264,6 +314,8 @@ class DiagnosticsEngine:
|
||||
def __init__(self, rules: list[Rule] | None = None):
|
||||
self.rules = rules if rules is not None else load_rules()
|
||||
self._rule_by_id = {r.id: r for r in self.rules}
|
||||
self._pattern_rules = [r for r in self.rules if r.pattern is not None]
|
||||
self.error_ids = kota.load_error_ids()
|
||||
self.reset()
|
||||
|
||||
# ---------------------------------------------------------------- API
|
||||
@@ -278,6 +330,19 @@ class DiagnosticsEngine:
|
||||
self._tool_reset_at: float | None = None
|
||||
self._panic_tail = 0 # RAW lines after a panic line to keep
|
||||
self.technician: list[str] = []
|
||||
self.mac: str | None = None # from "ID: Device MAC address: ..." at boot
|
||||
self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line
|
||||
self.matter_qr: str | None = None # "KOTA: matter ... qr=MT:..."
|
||||
self.matter_manual: str | None = None
|
||||
self.kota_seen = False # firmware prints KOTA: lines
|
||||
self.console_api: int | None = None # "cmd ping ... api=N": box accepts USB commands
|
||||
self.wifi_config: dict[str, str] | None = None # last "cmd wifi_get" reply (never a password)
|
||||
self._reset_led_state()
|
||||
|
||||
def _reset_led_state(self) -> None:
|
||||
self.led_pattern = ""
|
||||
self.led_exact = False # a KOTA led line was seen this boot
|
||||
self.status: tuple[str, int] | None = None
|
||||
|
||||
@property
|
||||
def boot(self) -> BootRecord:
|
||||
@@ -290,6 +355,8 @@ class DiagnosticsEngine:
|
||||
if "reset issued by tool" in text:
|
||||
self._tool_reset_at = line.host_time
|
||||
return
|
||||
if _BROWNOUT in text:
|
||||
self.boot.brownout = True
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._new_boot(line.host_time, explicit=True)
|
||||
self.milestones["rom_boot"] = 0
|
||||
@@ -299,7 +366,7 @@ class DiagnosticsEngine:
|
||||
if line.reset_reason and "POWERON" in line.reset_reason:
|
||||
self.led, self.led_reason = "OFF", "power-on - firmware not started yet"
|
||||
return
|
||||
if line.kind == LineKind.LOG and line.uptime_ms is not None:
|
||||
if line.kind in (LineKind.LOG, LineKind.KOTA) and line.uptime_ms is not None:
|
||||
b = self.boot
|
||||
# Uptime jumped back to a fresh-boot value: the ROM banner was
|
||||
# missed (port reconnect, capture started late).
|
||||
@@ -316,10 +383,20 @@ class DiagnosticsEngine:
|
||||
self._panic_tail -= 1
|
||||
self._crash_finding().evidence.append(text)
|
||||
|
||||
if line.kind == LineKind.KOTA:
|
||||
self._on_kota(line)
|
||||
return
|
||||
|
||||
self.firmware.observe(text)
|
||||
if m := _MAC_LINE.search(text):
|
||||
self.mac = m.group(1).upper()
|
||||
if "Advertise commission parameter" in text:
|
||||
self.matter_advert = text
|
||||
matched = False
|
||||
for rule in self.rules:
|
||||
m = rule.pattern.search(text)
|
||||
hit: list[Finding] = []
|
||||
rule_text = line.match_text
|
||||
for rule in self._pattern_rules:
|
||||
m = rule.pattern.search(rule_text)
|
||||
if not m:
|
||||
continue
|
||||
matched = True
|
||||
@@ -328,7 +405,8 @@ class DiagnosticsEngine:
|
||||
groups = {k: v for k, v in m.groupdict().items() if v is not None}
|
||||
if rule.milestone and rule.milestone not in self.milestones:
|
||||
self.milestones[rule.milestone] = line.uptime_ms or 0
|
||||
if rule.led:
|
||||
# The box's own "KOTA: led" lines are exact; message rules only guess.
|
||||
if rule.led and not self.led_exact:
|
||||
self._set_led(rule.led, rule.led_from, text)
|
||||
for cid in rule.clears:
|
||||
for f in self.findings.values():
|
||||
@@ -338,12 +416,18 @@ class DiagnosticsEngine:
|
||||
if rule.id in ("sys.factory_reset",) or "Short press:" in text:
|
||||
self.boot.intentional_restart = True
|
||||
if rule.severity != Severity.IGNORE:
|
||||
self._hit(rule, groups, line)
|
||||
hit.append(self._hit(rule, groups, line))
|
||||
|
||||
self._stateful(line)
|
||||
if line.error_id:
|
||||
info = self.error_ids.get(line.error_id)
|
||||
for f in hit:
|
||||
f.error_ids[line.error_id] = info.action if info else ""
|
||||
if not matched:
|
||||
self._hit_error_id(line, info)
|
||||
if line.level == "E":
|
||||
self.boot.recent_errors.append(text)
|
||||
if not matched and line.tag:
|
||||
if not matched and line.tag and not line.error_id:
|
||||
self._hit_other(line)
|
||||
|
||||
def report(self) -> Report:
|
||||
@@ -357,7 +441,8 @@ class DiagnosticsEngine:
|
||||
# Worst consequence first, then the root's own severity, then order seen.
|
||||
roots.sort(key=lambda rc: (-rc.severity, -rc.finding.severity, rc.finding.first_uptime_ms or 0))
|
||||
|
||||
mid_run = not any(b.explicit for b in self.boots) and self.boot.index == 0
|
||||
mid_run = (not any(b.explicit or "i2c" in b.kota or "boot" in b.kota for b in self.boots)
|
||||
and self.boot.index == 0)
|
||||
if self.lines == 0:
|
||||
verdict, vsev = "Waiting for data from the sensorbox...", None
|
||||
elif roots and roots[0].severity >= Severity.WARNING:
|
||||
@@ -375,8 +460,12 @@ class DiagnosticsEngine:
|
||||
if not optional or mid in self.milestones]
|
||||
# Reached milestones in the order they happened, then the pending ones.
|
||||
ms.sort(key=lambda m: (m[2] is None, m[2] or 0))
|
||||
pattern = self.led_pattern
|
||||
return Report(verdict, vsev, self.led, self.led_reason, self.firmware, ms, roots,
|
||||
earlier, resolved, list(self.boots), self.lines, mid_run, list(self.technician))
|
||||
earlier, resolved, list(self.boots), self.lines, mid_run, list(self.technician),
|
||||
led_pattern=pattern, led_meaning=kota.led_meaning(self.led, pattern) if pattern else "",
|
||||
led_exact=self.led_exact, box=self._box_summary(),
|
||||
matter_qr=self.matter_qr, matter_manual=self.matter_manual)
|
||||
|
||||
def add_confirmed(self, key: str, severity: Severity, title: str, meaning: str,
|
||||
actions: tuple[str, ...]) -> Finding:
|
||||
@@ -411,6 +500,7 @@ class DiagnosticsEngine:
|
||||
self.led_reason = "restarting - LED keeps its last colour until the firmware starts"
|
||||
self.firmware = FirmwareInfo()
|
||||
self._panic_tail = 0
|
||||
self._reset_led_state()
|
||||
|
||||
def _set_led(self, colour: str, led_from: str | None, text: str) -> None:
|
||||
if led_from and self.led != led_from:
|
||||
@@ -428,6 +518,7 @@ class DiagnosticsEngine:
|
||||
key, rule.id, rule.severity, _fmt(rule.title, groups), _fmt(rule.meaning, groups),
|
||||
tuple(_fmt(a, groups) for a in rule.actions), rule.explained_by, rule.min_count)
|
||||
self._touch(f, line)
|
||||
return f
|
||||
|
||||
def _touch(self, f: Finding, line: LogLine) -> None:
|
||||
f.count += 1
|
||||
@@ -460,7 +551,7 @@ class DiagnosticsEngine:
|
||||
"sys.crash", Severity.CRITICAL, "Firmware stopped with an error and restarts",
|
||||
"The firmware hit a fatal error. The error printed just before it is the real cause.",
|
||||
("See the grouped cause; otherwise save the session log for the firmware team.",),
|
||||
tuple(_INIT_FAILURES))
|
||||
tuple(_INIT_FAILURES) + ("reboot.brownout",))
|
||||
|
||||
def _on_panic(self, line: LogLine) -> None:
|
||||
b = self.boot
|
||||
@@ -486,35 +577,381 @@ class DiagnosticsEngine:
|
||||
("Replace the RTC module (inside the case).",))
|
||||
self._touch(f, line)
|
||||
|
||||
# ------------------------------------------------------- KOTA: lines
|
||||
def _on_kota(self, line: LogLine) -> None:
|
||||
self.kota_seen = True
|
||||
rec, f = line.record or "", line.fields
|
||||
# A second boot line in one boot: the ROM banner and the uptime drop
|
||||
# were both missed (e.g. capture started with a gap).
|
||||
if rec == "boot" and "boot" in self.boot.kota:
|
||||
self._new_boot(line.host_time, explicit=False)
|
||||
key = f"{rec}.verify" if rec == "rtc" and "verify" in line.words else rec
|
||||
self.boot.kota[key] = line
|
||||
handler = getattr(self, f"_kota_{rec}", None)
|
||||
if handler is not None:
|
||||
handler(line, f)
|
||||
|
||||
def _raise(self, rule_id: str, line: LogLine, groups: dict[str, str] | None = None) -> Finding:
|
||||
"""Raise a rules.toml finding from parsed KOTA fields (no pattern matching)."""
|
||||
return self._hit(self._rule_by_id[rule_id], groups or {}, line)
|
||||
|
||||
def _resolve(self, rule_id: str, by: str, key: str | None = None) -> None:
|
||||
for f in self.findings.values():
|
||||
if f.rule_id == rule_id and (key is None or f.key == key) and not f.resolved:
|
||||
f.resolved, f.resolved_by = True, by
|
||||
|
||||
def _milestone(self, mid: str, line: LogLine) -> None:
|
||||
if mid not in self.milestones:
|
||||
self.milestones[mid] = line.uptime_ms or 0
|
||||
|
||||
def _kota_boot(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
b = self.boot
|
||||
self.firmware.observe_kota(f)
|
||||
mac = f.get("mac", "")
|
||||
if re.fullmatch(r"[0-9A-Fa-f]{12}", mac):
|
||||
self.mac = ":".join(mac[i:i + 2] for i in range(0, 12, 2)).upper()
|
||||
b.reset_reason = b.reset_reason or f.get("reset")
|
||||
b.last_restart = f.get("last_restart")
|
||||
b.mode = f.get("mode")
|
||||
self._milestone("app_started", line)
|
||||
|
||||
def _kota_restart(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
reason = f.get("reason", "")
|
||||
self.boot.restart_reason = reason
|
||||
if reason in kota.INTENTIONAL_RESTARTS:
|
||||
self.boot.intentional_restart = True
|
||||
|
||||
def _kota_i2c(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if f.get("bus") == "stuck":
|
||||
self._raise("kota.i2c_stuck", line, {"sda": f.get("SDA", "?"), "scl": f.get("SCL", "?")})
|
||||
return
|
||||
self._resolve("kota.i2c_stuck", "I2C bus free again")
|
||||
missing = False
|
||||
for addr, value in f.items():
|
||||
if not addr.startswith("0x") or value == "?":
|
||||
continue
|
||||
name = value.removesuffix("(missing)")
|
||||
if value.endswith("(missing)"):
|
||||
missing = True
|
||||
self._raise("kota.sensor_missing", line, {"sensor": name, "addr": addr})
|
||||
else:
|
||||
self._resolve("kota.sensor_missing", f"{name} found on I2C", f"kota.sensor_missing:{name}")
|
||||
if not missing:
|
||||
self._milestone("i2c_ok", line)
|
||||
|
||||
def _kota_rtc(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if "verify" in line.words:
|
||||
if f.get("ok") == "1":
|
||||
self._milestone("clock_verified", line)
|
||||
self._resolve("kota.rtc_not_ticking", "clock verified")
|
||||
else:
|
||||
self._raise("kota.rtc_not_ticking", line,
|
||||
{"running": f.get("running", "?"), "delta": f.get("delta", "no reading")})
|
||||
elif "read_failed" in line.words:
|
||||
self._raise("kota.rtc_read_failed", line, {"err": f.get("err", "?")})
|
||||
elif f.get("running") == "0":
|
||||
self._raise("kota.rtc_stopped", line, {"time": f.get("time", "?")})
|
||||
elif f.get("running") == "1":
|
||||
self._resolve("kota.rtc_read_failed", "clock answers")
|
||||
self._resolve("kota.rtc_stopped", "clock running")
|
||||
|
||||
def _kota_sd(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if f.get("mounted") == "0":
|
||||
self._raise("kota.sd_unmounted", line, {"err": f.get("err", "card missing or unreadable")})
|
||||
return
|
||||
self._milestone("sd_mounted", line)
|
||||
self._resolve("kota.sd_unmounted", "SD card mounted")
|
||||
keys = {k for k in f.get("env_keys", "").split(",") if k and k != "-"}
|
||||
missing = [k for k in ("INFLUX_URL", "INFLUX_TOKEN") if k not in keys]
|
||||
for k in missing:
|
||||
self._raise("influx.env_missing", line, {"key": k})
|
||||
if not missing:
|
||||
self._milestone("env_loaded", line)
|
||||
if f.get("fs") and f["fs"] != "FAT32":
|
||||
self._raise("kota.sd_not_fat32", line, {"fs": f["fs"]})
|
||||
free = kota.parse_size_gb(f.get("free", ""))
|
||||
if free is not None and free < 0.1:
|
||||
self._raise("kota.sd_full", line, {"free": f["free"]})
|
||||
lines = f.get("buffer_lines", "0")
|
||||
if lines.isdigit() and int(lines) > 500:
|
||||
self._raise("kota.buffer_backlog", line, {"lines": lines})
|
||||
|
||||
def _kota_matter(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if f.get("qr", "-").startswith("MT:"):
|
||||
self.matter_qr = f["qr"]
|
||||
if f.get("manual", "-") != "-":
|
||||
self.matter_manual = f["manual"]
|
||||
fabrics = f.get("fabrics", "")
|
||||
if fabrics == "0":
|
||||
self._raise("matter.not_commissioned", line)
|
||||
elif fabrics.isdigit():
|
||||
self._milestone("matter_commissioned", line)
|
||||
self._resolve("matter.not_commissioned", "paired in Matter")
|
||||
|
||||
def _kota_mic(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if f.get("enabled") == "0":
|
||||
self._raise("mic.disabled", line)
|
||||
elif f.get("enabled") == "1":
|
||||
self._resolve("mic.disabled", "microphone switched on")
|
||||
|
||||
def _kota_status(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
w = line.words
|
||||
new = w[w.index("->") + 1] if "->" in w and w.index("->") + 1 < len(w) else (w[-1] if w else "")
|
||||
code = int(f["code"]) if f.get("code", "").isdigit() else 0
|
||||
self._apply_status(new, code, line, f.get("reason", ""))
|
||||
|
||||
def _apply_status(self, status: str, code: int, line: LogLine, reason: str) -> None:
|
||||
self.status = (status, code)
|
||||
if not self.led_exact:
|
||||
self.led, self.led_pattern = kota.led_for_status(status, code)
|
||||
self.led_reason = f"status {status}" + (f" code={code}" if code else "") + (
|
||||
f", reason: {reason}" if reason else "")
|
||||
sf = kota.status_finding(status, code)
|
||||
key = sf[0] if sf else None
|
||||
for fd in self.findings.values():
|
||||
if fd.rule_id.startswith("status.") and fd.key != key and not fd.resolved:
|
||||
fd.resolved, fd.resolved_by = True, f"status -> {status}"
|
||||
if sf:
|
||||
key, sev, title, meaning, actions, expl = sf
|
||||
fd = self._custom(key, Severity[sev.upper()], title, meaning, actions, expl)
|
||||
self._touch(fd, line)
|
||||
|
||||
def _kota_cmd(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
"""Replies to commands sent by this tool (firmware usb_console)."""
|
||||
name = line.words[0] if line.words else ""
|
||||
if f.get("ok") != "1":
|
||||
return # the Wi-Fi tab reports failures itself
|
||||
if name == "ping":
|
||||
api = f.get("api", "")
|
||||
self.console_api = int(api) if api.isdigit() else None
|
||||
elif name == "health":
|
||||
self.boot.kota["health"] = line # same fields as the periodic health line
|
||||
self._kota_health(line, f)
|
||||
elif name == "wifi_get":
|
||||
self.wifi_config = {k: f.get(k, "?") for k in ("ssid", "password_set", "mode")}
|
||||
|
||||
def _kota_led(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if "previous_boot" in line.words:
|
||||
code = f.get("code", "0")
|
||||
self.boot.previous_status = (f.get("status", "?"), int(code) if code.isdigit() else 0)
|
||||
return
|
||||
if "restart_flash" in line.words:
|
||||
return
|
||||
colour = kota.LED_COLOURS.get(f.get("color", ""))
|
||||
if colour is None:
|
||||
return
|
||||
pattern = kota.pattern_text(f)
|
||||
self.led, self.led_pattern, self.led_exact = ("OFF", "", True) if pattern == "off" else (colour, pattern, True)
|
||||
if not self.led_reason or self.status is None:
|
||||
self.led_reason = "reported by the box"
|
||||
|
||||
def _kota_health(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
up = kota.parse_seconds(f.get("up", "")) or 0
|
||||
status = f.get("status", "")
|
||||
# Connected mid-run: the health line is the first exact status we get.
|
||||
if status and (self.status is None or self.status[0] != status):
|
||||
self._apply_status(status, 0, line, "from the health report")
|
||||
mode = self.boot.mode
|
||||
wifi = f.get("wifi")
|
||||
rssi = int(f["rssi"]) if f.get("rssi", "").lstrip("-").isdigit() else None
|
||||
if wifi == "connected":
|
||||
self._milestone("wifi_connected", line)
|
||||
self._resolve("kota.wifi_down", "Wi-Fi connected")
|
||||
if rssi is not None and rssi < -75:
|
||||
self._raise("kota.weak_wifi", line, {"rssi": str(rssi)})
|
||||
elif rssi is not None:
|
||||
self._resolve("kota.weak_wifi", "signal OK")
|
||||
elif wifi == "disconnected" and mode != "provisioning":
|
||||
self._raise("kota.wifi_down", line)
|
||||
if f.get("time_valid") == "1":
|
||||
self._milestone("time_synced", line)
|
||||
self._resolve("kota.time_invalid", "time valid")
|
||||
elif f.get("time_valid") == "0" and up >= 120:
|
||||
self._raise("kota.time_invalid", line)
|
||||
if f.get("sd") == "missing":
|
||||
self._raise("kota.sd_unmounted", line, {"err": "reported missing by the health check"})
|
||||
last = f.get("last_upload", "never")
|
||||
if last != "never":
|
||||
self._milestone("data_uploaded", line)
|
||||
self._resolve("kota.no_upload", "data uploaded")
|
||||
elif wifi == "connected" and up >= 300:
|
||||
self._raise("kota.no_upload", line)
|
||||
heap = f.get("heap", "")
|
||||
if heap.isdigit() and int(heap) < 20_000:
|
||||
self._raise("kota.low_heap", line, {"heap": heap})
|
||||
ota = f.get("ota")
|
||||
if ota == "registered":
|
||||
self._milestone("ota_registered", line)
|
||||
self._resolve("kota.ota_unregistered", "registered")
|
||||
elif ota == "unregistered" and wifi == "connected" and mode != "provisioning":
|
||||
self._raise("kota.ota_unregistered", line)
|
||||
|
||||
def _hit_error_id(self, line: LogLine, info: kota.ErrorId | None) -> None:
|
||||
eid = line.error_id or "?"
|
||||
msg = (line.message or "").strip()
|
||||
msg = msg if len(msg) <= 90 else msg[:87] + "..."
|
||||
if info:
|
||||
sev = Severity.WARNING if info.severity == "warning" else Severity.ERROR
|
||||
f = self._custom(f"eid.{eid}", sev, f"{info.module_name}: {msg}",
|
||||
f"The firmware reported error {eid} ({info.module_name}).",
|
||||
(info.action, "If it repeats, save the session log for the firmware team."))
|
||||
f.rule_id = f"eid.{info.module}"
|
||||
f.error_ids[eid] = info.action
|
||||
else:
|
||||
f = self._custom(f"eid.{eid}", Severity.WARNING, f"Error {eid}: {msg}",
|
||||
"This error ID is not in the tool's error table (newer firmware?).",
|
||||
("Save the session log and send it to the firmware team.",))
|
||||
self._touch(f, line)
|
||||
|
||||
def _box_summary(self) -> list[tuple[str, str, str]]:
|
||||
"""What the box reported about itself in this boot (KOTA lines), for the panel."""
|
||||
k = self.boot.kota
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if ln := k.get("boot"):
|
||||
f = ln.fields
|
||||
lr = f.get("last_restart", "none")
|
||||
bad = kota.restart_finding(lr) is not None
|
||||
out.append(("Boot", f"v{f.get('fw', '?')} build {f.get('build', '?')}, mode {f.get('mode', '?')}, "
|
||||
f"reset {f.get('reset', '?')}, last restart: {lr}", "warn" if bad else "ok"))
|
||||
if ps := self.boot.previous_status:
|
||||
if ps[0] != "OK":
|
||||
out.append(("Before restart", f"status {ps[0]}" + (f" code={ps[1]}" if ps[1] else ""), "warn"))
|
||||
if ln := k.get("i2c"):
|
||||
f = ln.fields
|
||||
if f.get("bus") == "stuck":
|
||||
out.append(("I2C", f"bus stuck (SDA={f.get('SDA')}, SCL={f.get('SCL')})", "bad"))
|
||||
else:
|
||||
devs = [(v.removesuffix("(missing)"), v.endswith("(missing)"))
|
||||
for a, v in f.items() if a.startswith("0x") and v != "?"]
|
||||
miss = [n for n, m in devs if m]
|
||||
text = ("missing: " + ", ".join(miss) + "; " if miss else "") + "found: " + (
|
||||
", ".join(n for n, m in devs if not m) or "none")
|
||||
out.append(("I2C", text, "bad" if miss else "ok"))
|
||||
if ln := k.get("sd"):
|
||||
f = ln.fields
|
||||
if f.get("mounted") == "0":
|
||||
out.append(("SD card", "not mounted" + (f" ({f['err']})" if f.get("err") else ""), "bad"))
|
||||
else:
|
||||
keys = f.get("env_keys", "-")
|
||||
ok = "INFLUX_URL" in keys and "INFLUX_TOKEN" in keys
|
||||
out.append(("SD card", f"{f.get('type', '?')} {f.get('size', '?')} {f.get('fs', '?')}, "
|
||||
f"{f.get('free', '?')} free, ENV keys: {keys}, buffered: {f.get('buffer_lines', '?')}",
|
||||
"ok" if ok else "bad"))
|
||||
rtc, verify = k.get("rtc"), k.get("rtc.verify")
|
||||
if rtc or verify:
|
||||
parts, state = [], "ok"
|
||||
if rtc:
|
||||
if "read_failed" in rtc.words:
|
||||
parts.append(f"read failed ({rtc.fields.get('err', '?')})")
|
||||
state = "bad"
|
||||
else:
|
||||
running = rtc.fields.get("running") == "1"
|
||||
parts.append(("running" if running else "STOPPED") + f", time {rtc.fields.get('time', '?')}")
|
||||
state = "ok" if running else "warn"
|
||||
if verify:
|
||||
ok = verify.fields.get("ok") == "1"
|
||||
parts.append("verified ticking" if ok else "NOT keeping time")
|
||||
state = state if ok else "bad"
|
||||
out.append(("Clock (RTC)", "; ".join(parts), state))
|
||||
if ln := k.get("matter"):
|
||||
f = ln.fields
|
||||
fabrics = f.get("fabrics", "?")
|
||||
out.append(("Matter", f"{'paired' if fabrics not in ('0', '?') else 'NOT paired'} (fabrics {fabrics}), "
|
||||
f"pairing window {f.get('window', '?')}", "ok" if fabrics not in ("0", "?") else "warn"))
|
||||
if ln := k.get("mic"):
|
||||
on = ln.fields.get("enabled") == "1"
|
||||
out.append(("Microphone", "on" if on else "switched OFF (switch on the back)", "ok" if on else "info"))
|
||||
if self.wifi_config:
|
||||
from ..serialio.commands import unhex_utf8
|
||||
w = self.wifi_config
|
||||
ssid = unhex_utf8(w["ssid"])
|
||||
out.append(("Wi-Fi config", (f"network '{ssid}', password {'set' if w['password_set'] == '1' else 'NOT set'}"
|
||||
if ssid else "no network on the SD card") + f", mode {w['mode']}", "info"))
|
||||
if ln := k.get("health"):
|
||||
f = ln.fields
|
||||
wifi = f.get("wifi", "?")
|
||||
text = (f"up {f.get('up', '?')}, status {f.get('status', '?')}, Wi-Fi {wifi}"
|
||||
+ (f" ({f.get('rssi')} dBm)" if wifi == "connected" else "")
|
||||
+ f", time {'OK' if f.get('time_valid') == '1' else 'NOT set'}, SD {f.get('sd', '?')}, "
|
||||
f"buffer {f.get('buffer', '?')}, heap {f.get('heap', '?')}, last upload {f.get('last_upload', '?')}"
|
||||
+ ("" if f.get("last_upload") == "never" else " ago") + f", OTA {f.get('ota', '?')}")
|
||||
good = f.get("status") == "OK" and wifi == "connected" and f.get("last_upload") != "never"
|
||||
out.append(("Health", text, "ok" if good else "warn"))
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------ reboots
|
||||
def _reboot_findings(self) -> list[Finding]:
|
||||
"""Classify why each earlier boot ended (needs the next boot's reset reason)."""
|
||||
out: list[Finding] = []
|
||||
short_unexpected: list[BootRecord] = []
|
||||
cur = self.boot
|
||||
# The box keeps its status across a restart ("led previous_boot"): if
|
||||
# it was in an error state right before the latest restart (often the
|
||||
# technician's Reset & capture), that state is worth showing.
|
||||
if cur.previous_status and cur.previous_status[0] not in ("OK", "UPDATING", "SELF_CHECK"):
|
||||
sf = kota.status_finding(*cur.previous_status)
|
||||
st, code = cur.previous_status
|
||||
# Only informative once the box reports OK again in this boot.
|
||||
sev = Severity.INFO if self.status and self.status[0] == "OK" else Severity.WARNING
|
||||
f = Finding("boot.previous_status", "boot.previous_status", sev,
|
||||
f"Before the last restart: {sf[2] if sf else st}",
|
||||
"The box remembers its status across a restart. Right before the latest restart it was in "
|
||||
f"state {st}" + (f" code={code}" if code else "") + ". "
|
||||
+ (sf[3] if sf else "") + " Check whether the problem comes back in this boot.",
|
||||
sf[4] if sf else ())
|
||||
f.count, f.boot, f.last_seen = 1, cur.index, cur.host_time
|
||||
f.evidence.append(f"led previous_boot status={st} code={code}")
|
||||
out.append(f)
|
||||
for prev, nxt in zip(self.boots, self.boots[1:]):
|
||||
if not prev.saw_log and prev.index == 0:
|
||||
continue # nothing captured before the first boot
|
||||
reason = nxt.reset_reason or "unknown"
|
||||
if nxt.tool_reset or prev.intentional_restart:
|
||||
continue
|
||||
# Firmware 1.0.16+ records why it restarted itself.
|
||||
last = nxt.last_restart or prev.restart_reason
|
||||
if last in kota.INTENTIONAL_RESTARTS:
|
||||
continue
|
||||
key, sev, title, meaning, actions, expl = None, Severity.INFO, "", "", (), ()
|
||||
if "USB_UART_CHIP_RESET" in reason:
|
||||
rf = kota.restart_finding(last) if last else None
|
||||
if prev.brownout or "BROWN" in reason or last == "brownout":
|
||||
# A crash right after a brown-out is its effect, not a firmware bug.
|
||||
key, sev, title = "reboot.brownout", Severity.CRITICAL, "Power supply voltage drops (brown-out)"
|
||||
meaning = ("The supply voltage fell too low and the chip reset itself - typically when the "
|
||||
"Wi-Fi/Bluetooth radio starts. The box cannot run like this.")
|
||||
actions = ("Switch the box's power switch ON - USB from a laptop alone may not be enough.",
|
||||
"Check the power supply, cable and connectors; try another supply.",
|
||||
"If it persists on a good supply: the board's power circuit is faulty.")
|
||||
elif rf and not (last == "panic" and prev.panic_lines):
|
||||
sev_name, title, meaning, actions = rf
|
||||
sev = Severity[sev_name.upper()]
|
||||
key = {"status_watchdog": "reboot.status_watchdog", "panic": "reboot.crash",
|
||||
"brownout": "reboot.brownout", "task_wdt": "reboot.watchdog", "int_wdt": "reboot.watchdog",
|
||||
"wdt": "reboot.watchdog"}.get(last, f"reboot.{last}")
|
||||
if nxt.previous_status and nxt.previous_status[0] != "OK":
|
||||
st, code = nxt.previous_status
|
||||
meaning += f" Before the restart the box was in state {st}" + (f" code={code}" if code else "") + "."
|
||||
if last == "status_watchdog":
|
||||
expl = tuple(_INIT_FAILURES) + kota._SENSOR_ROOTS + ("status.error", "sys.out_of_resources")
|
||||
elif last == "status_oom_watchdog":
|
||||
expl = ("sys.out_of_resources",)
|
||||
elif rf:
|
||||
pass # panic with captured crash output: covered by sys.crash
|
||||
elif "USB_UART_CHIP_RESET" in reason:
|
||||
key, title = "reboot.usb", "Box was reset over USB"
|
||||
meaning = "The board was reset through its USB port (a flashing tool, a serial program, or this tool in an earlier session)."
|
||||
elif "POWERON" in reason:
|
||||
key, title = "reboot.power", "Box was switched off and on"
|
||||
meaning = "Power was interrupted (power switch, cable or supply)."
|
||||
actions = ("If nobody switched it off: check the power supply and cable.",)
|
||||
elif "BROWN" in reason:
|
||||
key, sev, title = "reboot.brownout", Severity.ERROR, "Power supply voltage dropped (brown-out)"
|
||||
meaning = "The supply voltage fell too low and the chip reset itself."
|
||||
actions = ("Check the power supply, cable and connectors.",)
|
||||
elif "WDT" in reason:
|
||||
key, sev, title = "reboot.watchdog", Severity.ERROR, "Firmware froze and was restarted by the watchdog"
|
||||
meaning = "The firmware stopped responding; the hardware watchdog restarted it."
|
||||
actions = ("Save the session log for the firmware team.",)
|
||||
elif prev.panic_lines:
|
||||
pass # covered by sys.crash
|
||||
elif prev.led_at_end in ("PURPLE", "RED") and prev.last_uptime_ms >= 140_000:
|
||||
elif (not nxt.last_restart and prev.led_at_end in ("PURPLE", "RED")
|
||||
and prev.last_uptime_ms >= 140_000): # older firmware: no restart record
|
||||
key, title = "reboot.status_watchdog", "Box restarted itself after 2.5 minutes of errors"
|
||||
meaning = "By design the firmware restarts when it stays in an error state (purple/red) for 150 s."
|
||||
actions = ("Fix the grouped cause; the restarts stop by themselves.",)
|
||||
@@ -533,10 +970,18 @@ class DiagnosticsEngine:
|
||||
"reboot.loop", Severity.CRITICAL, "Reboot loop",
|
||||
f"The box restarted {len(short_unexpected)} times, about every {avg:.0f} s. "
|
||||
"The error printed just before each restart is the cause.",
|
||||
("Fix the grouped cause below.",), tuple(_INIT_FAILURES), self.boot)
|
||||
("Fix the grouped cause below.",), tuple(_INIT_FAILURES) + ("reboot.brownout",), self.boot)
|
||||
f.count = len(short_unexpected)
|
||||
out.append(f)
|
||||
return out
|
||||
# One finding per cause, counting how often it happened.
|
||||
merged: dict[str, Finding] = {}
|
||||
for f in out:
|
||||
if f.key in merged:
|
||||
merged[f.key].count += f.count
|
||||
merged[f.key].evidence.extend(f.evidence)
|
||||
else:
|
||||
merged[f.key] = f
|
||||
return list(merged.values())
|
||||
|
||||
def _reboot_finding(self, key, sev, title, meaning, actions, expl, boot: BootRecord) -> Finding:
|
||||
f = Finding(key, key, sev, title, meaning, actions, expl)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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
|
||||
E-CON-01,CON,error,Failed to install USB-Serial-JTAG driver: %s,Internal error - send the log to the firmware team
|
||||
|
@@ -1,8 +1,14 @@
|
||||
"""Firmware identification from the boot log.
|
||||
|
||||
The app version string is "1.0" in every build seen so far, so builds are
|
||||
told apart by the ELF SHA256 prefix printed by app_init. Boxes receive new
|
||||
firmware over OTA; an unknown SHA usually means a box has already updated.
|
||||
Up to firmware 1.0 the app version string was "1.0" in every build, so
|
||||
builds are told apart by the ELF SHA256 prefix printed by app_init. Boxes
|
||||
receive new firmware over OTA; an unknown SHA usually means a box has already
|
||||
updated.
|
||||
|
||||
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
|
||||
tool's firmware/ folder are registered with `register_local_build` so the
|
||||
box's build can be shown with its compile date.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,8 +32,35 @@ KNOWN: dict[str, KnownFirmware] = {
|
||||
sd_wifi=False,
|
||||
setup_portal=False,
|
||||
),
|
||||
"c94ba0e9b": KnownFirmware(
|
||||
name="v1.0 source build (Sep 26 2026)",
|
||||
source="developer build, built by Krzysztof Kallas",
|
||||
sd_wifi=True,
|
||||
setup_portal=True,
|
||||
),
|
||||
}
|
||||
|
||||
# elf sha (full or prefix) -> (version, compile time, file name) of builds in firmware/
|
||||
LOCAL_BUILDS: dict[str, tuple[str, str, str]] = {}
|
||||
|
||||
|
||||
def register_local_build(elf_sha: str, version: str, built: str, file_name: str) -> None:
|
||||
LOCAL_BUILDS[elf_sha.lower()] = (version, built, file_name)
|
||||
|
||||
|
||||
def same_build(a: str | None, b: str | None) -> bool:
|
||||
"""ELF SHA prefixes of different lengths (8 from KOTA: boot, 9+ from
|
||||
app_init, 64 from an image) name the same build if they agree."""
|
||||
if not a or not b:
|
||||
return False
|
||||
n = min(len(a), len(b))
|
||||
return n >= 6 and a[:n].lower() == b[:n].lower()
|
||||
|
||||
|
||||
def local_build(elf_sha: str | None) -> tuple[str, str, str] | None:
|
||||
return next((v for sha, v in LOCAL_BUILDS.items() if same_build(sha, elf_sha)), None)
|
||||
|
||||
|
||||
_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+)")
|
||||
_BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$")
|
||||
@@ -45,8 +78,21 @@ class FirmwareInfo:
|
||||
version: str | None = None
|
||||
built: str | None = None
|
||||
idf: str | None = None
|
||||
hw: str | None = None
|
||||
evidence: set[str] = field(default_factory=set)
|
||||
|
||||
def observe_kota(self, fields: dict[str, str]) -> None:
|
||||
"""'KOTA: boot fw=1.0.16 build=4f10989e hw=2 ...'"""
|
||||
if fields.get("fw"):
|
||||
self.version = fields["fw"]
|
||||
build = fields.get("build", "")
|
||||
# app_init's longer prefix is kept if it agrees.
|
||||
if build and not same_build(self.elf_sha, build):
|
||||
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")
|
||||
@@ -67,14 +113,14 @@ class FirmwareInfo:
|
||||
def known(self) -> KnownFirmware | None:
|
||||
if not self.elf_sha:
|
||||
return None
|
||||
return next((k for sha, k in KNOWN.items() if self.elf_sha.startswith(sha)), 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:
|
||||
if "sd_wifi" in self.evidence or "kota" in self.evidence:
|
||||
return True
|
||||
if "legacy_v1" in self.evidence:
|
||||
return False
|
||||
@@ -83,6 +129,16 @@ class FirmwareInfo:
|
||||
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)"
|
||||
|
||||
+401
-400
@@ -1,400 +1,401 @@
|
||||
"""Guided troubleshooting: executes the decision trees in guides.toml.
|
||||
|
||||
GUI-independent. The runner is fed the same LogLines as the diagnostics
|
||||
engine, reads the engine's report to route and to skip questions the log
|
||||
already answers, asks the host to perform tool actions (board reset), and
|
||||
records every answer and conclusion in the engine so they appear in the
|
||||
report and the session log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import tomllib
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.logline import LogLine
|
||||
from .engine import LED, DiagnosticsEngine, Severity
|
||||
|
||||
GUIDES_PATH = Path(__file__).with_name("guides.toml")
|
||||
# LED answers a technician can give (engine colours + a reboot-loop pattern).
|
||||
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "OFF", "CYCLING"]
|
||||
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
|
||||
"CYCLING": "Keeps changing / goes dark every few seconds"}
|
||||
STEP_TYPES = {"led", "question", "info", "wait", "route"}
|
||||
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
|
||||
"error": Severity.ERROR, "critical": Severity.CRITICAL}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Option:
|
||||
label: str
|
||||
next: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Step:
|
||||
id: str
|
||||
type: str
|
||||
text: str = ""
|
||||
options: tuple[Option, ...] = ()
|
||||
route: dict[str, str] = field(default_factory=dict) # led colour -> target
|
||||
routes: dict[str, str] = field(default_factory=dict) # rule id -> target
|
||||
default: str | None = None
|
||||
next: str | None = None
|
||||
tool_action: str | None = None
|
||||
until: re.Pattern[str] | None = None
|
||||
until_milestone: str | None = None
|
||||
fail_on: re.Pattern[str] | None = None
|
||||
timeout_s: float = 60.0
|
||||
pass_on_timeout: bool = False
|
||||
on_ok: str | None = None
|
||||
on_fail: str | None = None
|
||||
on_timeout: str | None = None
|
||||
skip_if_finding: str | None = None
|
||||
skip_unless_feature: str | None = None
|
||||
skip_to: str | None = None
|
||||
|
||||
def targets(self) -> list[str]:
|
||||
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
||||
t += [x for x in (self.default, self.next, self.on_ok, self.on_fail, self.on_timeout, self.skip_to) if x]
|
||||
return t
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class End:
|
||||
id: str
|
||||
severity: str
|
||||
title: str
|
||||
text: str
|
||||
actions: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Guide:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
recommend: tuple[str, ...]
|
||||
start: str
|
||||
steps: dict[str, Step]
|
||||
ends: dict[str, End]
|
||||
|
||||
|
||||
def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
guides: dict[str, Guide] = {}
|
||||
for g in data["guide"]:
|
||||
steps = {}
|
||||
for s in g.get("step", []):
|
||||
steps[s["id"]] = Step(
|
||||
id=s["id"], type=s["type"], text=s.get("text", ""),
|
||||
options=tuple(Option(o["label"], o["next"]) for o in s.get("options", ())),
|
||||
route=dict(s.get("route", {})), routes=dict(s.get("routes", {})),
|
||||
default=s.get("default"), next=s.get("next"), tool_action=s.get("tool_action"),
|
||||
until=re.compile(s["until"]) if "until" in s else None,
|
||||
until_milestone=s.get("until_milestone"),
|
||||
fail_on=re.compile(s["fail_on"]) if "fail_on" in s else None,
|
||||
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
|
||||
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
||||
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
|
||||
skip_to=s.get("skip_to"),
|
||||
)
|
||||
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
||||
for e in g.get("end", [])}
|
||||
guides[g["id"]] = Guide(g["id"], g["title"], g["summary"], tuple(g.get("recommend", ())),
|
||||
g["start"], steps, ends)
|
||||
_validate(guides)
|
||||
return guides
|
||||
|
||||
|
||||
def _validate(guides: dict[str, Guide]) -> None:
|
||||
for g in guides.values():
|
||||
if g.start not in g.steps:
|
||||
raise ValueError(f"guide {g.id}: start step {g.start!r} missing")
|
||||
for s in g.steps.values():
|
||||
if s.type not in STEP_TYPES:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown step type {s.type!r}")
|
||||
if s.type == "led" and set(s.route) - set(LED_ANSWERS):
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown LED colours {set(s.route) - set(LED_ANSWERS)}")
|
||||
if s.type == "wait" and not (s.until or s.until_milestone or s.pass_on_timeout):
|
||||
raise ValueError(f"{g.id}.{s.id}: wait step needs until/until_milestone/pass_on_timeout")
|
||||
if s.tool_action not in (None, "reset"):
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}")
|
||||
for t in s.targets():
|
||||
kind, _, ref = t.partition(":")
|
||||
if kind == "end" and ref not in g.ends:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown end {ref!r}")
|
||||
elif kind == "guide" and ref not in guides:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown guide {ref!r}")
|
||||
elif not ref and t not in g.steps:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown step {t!r}")
|
||||
for e in g.ends.values():
|
||||
if e.severity not in END_SEVERITY:
|
||||
raise ValueError(f"{g.id}.end.{e.id}: unknown severity {e.severity!r}")
|
||||
|
||||
|
||||
def recommended(guides: dict[str, Guide], engine: DiagnosticsEngine) -> list[Guide]:
|
||||
"""Guides matching the current root findings (in root order), then the LED."""
|
||||
report = engine.report()
|
||||
keys: list[str] = []
|
||||
for rc in report.roots:
|
||||
keys.append(rc.finding.rule_id)
|
||||
keys += [c.rule_id for c in rc.consequences]
|
||||
if report.led not in ("UNKNOWN",):
|
||||
keys.append(f"led:{report.led}")
|
||||
out: list[Guide] = [g for g in guides.values() if "always" in g.recommend]
|
||||
for k in keys:
|
||||
for g in guides.values():
|
||||
if k in g.recommend and g not in out:
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class View:
|
||||
"""What the UI should show right now."""
|
||||
guide: Guide | None = None
|
||||
step: Step | None = None
|
||||
text: str = ""
|
||||
options: list[tuple[str, str]] = field(default_factory=list) # (key, label)
|
||||
waiting: bool = False
|
||||
remaining_s: float = 0.0
|
||||
note: str = ""
|
||||
end: End | None = None
|
||||
can_back: bool = False
|
||||
|
||||
|
||||
class GuideRunner:
|
||||
def __init__(self, engine: DiagnosticsEngine, guides: dict[str, Guide] | None = None,
|
||||
on_action: Callable[[str], bool] | None = None,
|
||||
on_log: Callable[[str], None] | None = None,
|
||||
clock: Callable[[], float] = time.monotonic):
|
||||
self.engine = engine
|
||||
self.guides = guides if guides is not None else load_guides()
|
||||
self._on_action = on_action or (lambda _a: False)
|
||||
self._on_log = on_log or (lambda _t: None)
|
||||
self._clock = clock
|
||||
self.cancel()
|
||||
|
||||
# ------------------------------------------------------------ control
|
||||
def cancel(self) -> None:
|
||||
self.guide: Guide | None = None
|
||||
self.step: Step | None = None
|
||||
self.end: End | None = None
|
||||
self.note = ""
|
||||
self._stack: list[tuple[str, str]] = [] # (guide id, step id) for Back
|
||||
self._wait_started = 0.0
|
||||
self._wait_boot = 0
|
||||
self._wait_milestones: set[str] = set()
|
||||
|
||||
def start(self, guide_id: str) -> None:
|
||||
self.cancel()
|
||||
self._enter_guide(guide_id)
|
||||
|
||||
def answer(self, key: str) -> None:
|
||||
"""Pick an option: LED colour name for led steps, option index otherwise."""
|
||||
s = self.step
|
||||
if s is None:
|
||||
return
|
||||
if s.type == "led":
|
||||
label = LED_ANSWER_LABELS.get(key, key)
|
||||
self._record(s, label)
|
||||
self._check_led(key)
|
||||
target = s.route.get(key)
|
||||
elif s.type == "question":
|
||||
opt = s.options[int(key)]
|
||||
self._record(s, opt.label)
|
||||
target = opt.next
|
||||
else:
|
||||
return
|
||||
if target:
|
||||
self._push()
|
||||
self._go(target)
|
||||
|
||||
def proceed(self) -> None:
|
||||
"""Continue on info steps; 'skip waiting' on wait steps."""
|
||||
s = self.step
|
||||
if s is None:
|
||||
return
|
||||
if s.type == "info" and s.next:
|
||||
self._push()
|
||||
self._go(s.next)
|
||||
elif s.type == "wait":
|
||||
self._record(s, "technician skipped the wait")
|
||||
self._finish_wait("timeout")
|
||||
|
||||
def back(self) -> None:
|
||||
# Never step back into a wait: re-entering it could restart the box.
|
||||
while self._stack:
|
||||
gid, sid = self._stack.pop()
|
||||
if self.guides[gid].steps[sid].type != "wait":
|
||||
self.guide, self.end, self.note = self.guides[gid], None, ""
|
||||
self._enter_step(sid)
|
||||
return
|
||||
|
||||
# --------------------------------------------------------------- feed
|
||||
def feed(self, line: LogLine) -> None:
|
||||
s = self.step
|
||||
if s is None or s.type != "wait":
|
||||
return
|
||||
if s.fail_on and s.fail_on.search(line.text):
|
||||
self._record(s, f"log: {line.text}")
|
||||
self._finish_wait("fail")
|
||||
elif s.until and s.until.search(line.text):
|
||||
self._record(s, f"log: {line.text}")
|
||||
self._finish_wait("ok")
|
||||
elif s.until_milestone and self._milestone_new(s.until_milestone):
|
||||
self._record(s, f"milestone reached: {s.until_milestone}")
|
||||
self._finish_wait("ok")
|
||||
|
||||
def tick(self) -> None:
|
||||
s = self.step
|
||||
if s is not None and s.type == "wait" and self._clock() - self._wait_started >= s.timeout_s:
|
||||
self._finish_wait("ok" if s.pass_on_timeout else "timeout")
|
||||
|
||||
# --------------------------------------------------------------- view
|
||||
def view(self) -> View:
|
||||
v = View(guide=self.guide, step=self.step, end=self.end, note=self.note, can_back=bool(self._stack))
|
||||
s = self.step
|
||||
if s is None:
|
||||
return v
|
||||
v.text = self._render(s.text)
|
||||
if s.type == "led":
|
||||
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
|
||||
elif s.type == "question":
|
||||
v.options = [(str(i), o.label) for i, o in enumerate(s.options)]
|
||||
elif s.type == "wait":
|
||||
v.waiting = True
|
||||
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
|
||||
return v
|
||||
|
||||
# ---------------------------------------------------------- internals
|
||||
def _push(self) -> None:
|
||||
if self.guide and self.step:
|
||||
self._stack.append((self.guide.id, self.step.id))
|
||||
|
||||
def _enter_guide(self, guide_id: str) -> None:
|
||||
self.guide = self.guides[guide_id]
|
||||
self._on_log(f"technician: started guide '{self.guide.title}'")
|
||||
self._enter_step(self.guide.start)
|
||||
|
||||
def _go(self, target: str) -> None:
|
||||
kind, _, ref = target.partition(":")
|
||||
if kind == "end" and ref:
|
||||
self._conclude(self.guide.ends[ref])
|
||||
elif kind == "guide" and ref:
|
||||
self._enter_guide(ref)
|
||||
else:
|
||||
self._enter_step(target)
|
||||
|
||||
def _enter_step(self, step_id: str) -> None:
|
||||
s = self.guide.steps[step_id]
|
||||
self.step, self.note = s, ""
|
||||
if s.skip_if_finding and self._finding_active(s.skip_if_finding):
|
||||
self._auto(s, f"skipped - log already shows {s.skip_if_finding}", s.skip_to)
|
||||
return
|
||||
if s.skip_unless_feature and not self._feature(s.skip_unless_feature):
|
||||
self._auto(s, f"skipped - firmware has no {s.skip_unless_feature}", s.skip_to)
|
||||
return
|
||||
if s.type == "route":
|
||||
target = self._route(s)
|
||||
self._auto(s, f"routed by log findings -> {target}", target)
|
||||
return
|
||||
if s.type == "wait":
|
||||
self._wait_started = self._clock()
|
||||
self._wait_boot = self.engine.boot.index
|
||||
self._wait_milestones = set(self.engine.milestones)
|
||||
if s.tool_action and not self._on_action(s.tool_action):
|
||||
self.note = "Could not restart the box from the tool - connect to it first, or switch it off and on."
|
||||
# A milestone that is already reached satisfies the wait at once
|
||||
# unless the step first restarts the box.
|
||||
if s.until_milestone and not s.tool_action and s.until_milestone in self.engine.milestones:
|
||||
self._record(s, f"already reached: {s.until_milestone}")
|
||||
self._finish_wait("ok")
|
||||
|
||||
def _auto(self, s: Step, why: str, target: str | None) -> None:
|
||||
self._on_log(f"technician guide: {s.id}: {why}")
|
||||
if target:
|
||||
self._go(target)
|
||||
|
||||
def _finish_wait(self, outcome: str) -> None:
|
||||
s = self.step
|
||||
target = {"ok": s.on_ok, "fail": s.on_fail or s.on_timeout, "timeout": s.on_timeout or s.on_fail}[outcome]
|
||||
if target is None:
|
||||
return
|
||||
self._push()
|
||||
self._go(target)
|
||||
|
||||
def _milestone_new(self, mid: str) -> bool:
|
||||
if mid not in self.engine.milestones:
|
||||
return False
|
||||
return self.engine.boot.index != self._wait_boot or mid not in self._wait_milestones
|
||||
|
||||
def _route(self, s: Step) -> str:
|
||||
report = self.engine.report()
|
||||
for rc in report.roots:
|
||||
for f in (rc.finding, *rc.consequences):
|
||||
if f.rule_id in s.routes:
|
||||
return s.routes[f.rule_id]
|
||||
return s.default or "end:" + next(iter(self.guide.ends))
|
||||
|
||||
def _check_led(self, seen: str) -> None:
|
||||
expected = self.engine.led
|
||||
if expected in ("UNKNOWN", "OFF") or seen == "CYCLING" or seen == expected:
|
||||
return
|
||||
exp_label, see_label = LED[expected][0], LED_ANSWER_LABELS[seen]
|
||||
self.engine.add_confirmed(
|
||||
"tech.led_mismatch", Severity.WARNING,
|
||||
f"LED shows {see_label}, but the log implies {exp_label}",
|
||||
f"The firmware state in the log corresponds to {exp_label}, while the technician sees {see_label}. "
|
||||
"Either the LED just changed (check again in a few seconds), the LED/its wiring is faulty, "
|
||||
"or the box runs different firmware. Trust the log findings.",
|
||||
("Look at the LED again for 10 seconds.", "If it stays different: note it for repair (LED board)."))
|
||||
|
||||
def _conclude(self, end: End) -> None:
|
||||
self.step, self.end = None, end
|
||||
self._on_log(f"technician guide result: {end.title}")
|
||||
sev = END_SEVERITY[end.severity]
|
||||
self.engine.note_technician(f"{self.guide.title}: {end.title}")
|
||||
if sev is not None:
|
||||
self.engine.add_confirmed(f"tech.{self.guide.id}.{end.id}", sev, end.title, end.text, end.actions)
|
||||
|
||||
def _record(self, s: Step, answer: str) -> None:
|
||||
q = self._render(s.text)
|
||||
self.engine.note_technician(f"{q} -> {answer}")
|
||||
self._on_log(f"technician: {q[:80]} -> {answer}")
|
||||
|
||||
def _finding_active(self, rule_id: str) -> bool:
|
||||
return any(f.rule_id == rule_id and f.active for f in self.engine.findings.values())
|
||||
|
||||
def _feature(self, name: str) -> bool:
|
||||
fw = self.engine.firmware
|
||||
if name == "sd_wifi":
|
||||
return fw.supports_sd_wifi is not False
|
||||
if name == "setup_portal":
|
||||
if fw.known is not None:
|
||||
return fw.known.setup_portal
|
||||
if "setup_portal" in fw.evidence:
|
||||
return True
|
||||
return "legacy_v1" not in fw.evidence # unknown firmware: ask
|
||||
return False
|
||||
|
||||
def _render(self, text: str) -> str:
|
||||
report = self.engine.report()
|
||||
sensors = sorted({f.title.removesuffix(" failed to start") for f in self.engine.findings.values()
|
||||
if f.rule_id == "sensor.init_failed" and f.active})
|
||||
ctx = {
|
||||
"expected_led": LED[report.led][0],
|
||||
"failing_sensors": ", ".join(sensors) or "see the Diagnosis tab",
|
||||
"top_finding": report.roots[0].finding.title if report.roots else "none",
|
||||
}
|
||||
return text.format_map(_Fmt(ctx))
|
||||
|
||||
|
||||
class _Fmt(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
"""Guided troubleshooting: executes the decision trees in guides.toml.
|
||||
|
||||
GUI-independent. The runner is fed the same LogLines as the diagnostics
|
||||
engine, reads the engine's report to route and to skip questions the log
|
||||
already answers, asks the host to perform tool actions (board reset), and
|
||||
records every answer and conclusion in the engine so they appear in the
|
||||
report and the session log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import tomllib
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.logline import LogLine
|
||||
from .engine import LED, DiagnosticsEngine, Severity
|
||||
|
||||
GUIDES_PATH = Path(__file__).with_name("guides.toml")
|
||||
# LED answers a technician can give (engine colours + a reboot-loop pattern).
|
||||
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "CYAN", "OFF", "CYCLING"]
|
||||
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
|
||||
"CYCLING": "Keeps changing / goes dark every few seconds"}
|
||||
STEP_TYPES = {"led", "question", "info", "wait", "route"}
|
||||
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
|
||||
"error": Severity.ERROR, "critical": Severity.CRITICAL}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Option:
|
||||
label: str
|
||||
next: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Step:
|
||||
id: str
|
||||
type: str
|
||||
text: str = ""
|
||||
options: tuple[Option, ...] = ()
|
||||
route: dict[str, str] = field(default_factory=dict) # led colour -> target
|
||||
routes: dict[str, str] = field(default_factory=dict) # rule id -> target
|
||||
default: str | None = None
|
||||
next: str | None = None
|
||||
tool_action: str | None = None
|
||||
until: re.Pattern[str] | None = None
|
||||
until_milestone: str | None = None
|
||||
fail_on: re.Pattern[str] | None = None
|
||||
timeout_s: float = 60.0
|
||||
pass_on_timeout: bool = False
|
||||
on_ok: str | None = None
|
||||
on_fail: str | None = None
|
||||
on_timeout: str | None = None
|
||||
skip_if_finding: str | None = None
|
||||
skip_unless_feature: str | None = None
|
||||
skip_to: str | None = None
|
||||
show_qr: bool = False # show the Matter pairing QR next to the step text
|
||||
|
||||
def targets(self) -> list[str]:
|
||||
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
||||
t += [x for x in (self.default, self.next, self.on_ok, self.on_fail, self.on_timeout, self.skip_to) if x]
|
||||
return t
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class End:
|
||||
id: str
|
||||
severity: str
|
||||
title: str
|
||||
text: str
|
||||
actions: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Guide:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
recommend: tuple[str, ...]
|
||||
start: str
|
||||
steps: dict[str, Step]
|
||||
ends: dict[str, End]
|
||||
|
||||
|
||||
def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
guides: dict[str, Guide] = {}
|
||||
for g in data["guide"]:
|
||||
steps = {}
|
||||
for s in g.get("step", []):
|
||||
steps[s["id"]] = Step(
|
||||
id=s["id"], type=s["type"], text=s.get("text", ""),
|
||||
options=tuple(Option(o["label"], o["next"]) for o in s.get("options", ())),
|
||||
route=dict(s.get("route", {})), routes=dict(s.get("routes", {})),
|
||||
default=s.get("default"), next=s.get("next"), tool_action=s.get("tool_action"),
|
||||
until=re.compile(s["until"]) if "until" in s else None,
|
||||
until_milestone=s.get("until_milestone"),
|
||||
fail_on=re.compile(s["fail_on"]) if "fail_on" in s else None,
|
||||
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
|
||||
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
||||
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
|
||||
skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
|
||||
)
|
||||
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
||||
for e in g.get("end", [])}
|
||||
guides[g["id"]] = Guide(g["id"], g["title"], g["summary"], tuple(g.get("recommend", ())),
|
||||
g["start"], steps, ends)
|
||||
_validate(guides)
|
||||
return guides
|
||||
|
||||
|
||||
def _validate(guides: dict[str, Guide]) -> None:
|
||||
for g in guides.values():
|
||||
if g.start not in g.steps:
|
||||
raise ValueError(f"guide {g.id}: start step {g.start!r} missing")
|
||||
for s in g.steps.values():
|
||||
if s.type not in STEP_TYPES:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown step type {s.type!r}")
|
||||
if s.type == "led" and set(s.route) - set(LED_ANSWERS):
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown LED colours {set(s.route) - set(LED_ANSWERS)}")
|
||||
if s.type == "wait" and not (s.until or s.until_milestone or s.pass_on_timeout):
|
||||
raise ValueError(f"{g.id}.{s.id}: wait step needs until/until_milestone/pass_on_timeout")
|
||||
if s.tool_action not in (None, "reset"):
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}")
|
||||
for t in s.targets():
|
||||
kind, _, ref = t.partition(":")
|
||||
if kind == "end" and ref not in g.ends:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown end {ref!r}")
|
||||
elif kind == "guide" and ref not in guides:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown guide {ref!r}")
|
||||
elif not ref and t not in g.steps:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown step {t!r}")
|
||||
for e in g.ends.values():
|
||||
if e.severity not in END_SEVERITY:
|
||||
raise ValueError(f"{g.id}.end.{e.id}: unknown severity {e.severity!r}")
|
||||
|
||||
|
||||
def recommended(guides: dict[str, Guide], engine: DiagnosticsEngine) -> list[Guide]:
|
||||
"""Guides matching the current root findings (in root order), then the LED."""
|
||||
report = engine.report()
|
||||
keys: list[str] = []
|
||||
for rc in report.roots:
|
||||
keys.append(rc.finding.rule_id)
|
||||
keys += [c.rule_id for c in rc.consequences]
|
||||
if report.led not in ("UNKNOWN",):
|
||||
keys.append(f"led:{report.led}")
|
||||
out: list[Guide] = [g for g in guides.values() if "always" in g.recommend]
|
||||
for k in keys:
|
||||
for g in guides.values():
|
||||
if k in g.recommend and g not in out:
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class View:
|
||||
"""What the UI should show right now."""
|
||||
guide: Guide | None = None
|
||||
step: Step | None = None
|
||||
text: str = ""
|
||||
options: list[tuple[str, str]] = field(default_factory=list) # (key, label)
|
||||
waiting: bool = False
|
||||
remaining_s: float = 0.0
|
||||
note: str = ""
|
||||
end: End | None = None
|
||||
can_back: bool = False
|
||||
|
||||
|
||||
class GuideRunner:
|
||||
def __init__(self, engine: DiagnosticsEngine, guides: dict[str, Guide] | None = None,
|
||||
on_action: Callable[[str], bool] | None = None,
|
||||
on_log: Callable[[str], None] | None = None,
|
||||
clock: Callable[[], float] = time.monotonic):
|
||||
self.engine = engine
|
||||
self.guides = guides if guides is not None else load_guides()
|
||||
self._on_action = on_action or (lambda _a: False)
|
||||
self._on_log = on_log or (lambda _t: None)
|
||||
self._clock = clock
|
||||
self.cancel()
|
||||
|
||||
# ------------------------------------------------------------ control
|
||||
def cancel(self) -> None:
|
||||
self.guide: Guide | None = None
|
||||
self.step: Step | None = None
|
||||
self.end: End | None = None
|
||||
self.note = ""
|
||||
self._stack: list[tuple[str, str]] = [] # (guide id, step id) for Back
|
||||
self._wait_started = 0.0
|
||||
self._wait_boot = 0
|
||||
self._wait_milestones: set[str] = set()
|
||||
|
||||
def start(self, guide_id: str) -> None:
|
||||
self.cancel()
|
||||
self._enter_guide(guide_id)
|
||||
|
||||
def answer(self, key: str) -> None:
|
||||
"""Pick an option: LED colour name for led steps, option index otherwise."""
|
||||
s = self.step
|
||||
if s is None:
|
||||
return
|
||||
if s.type == "led":
|
||||
label = LED_ANSWER_LABELS.get(key, key)
|
||||
self._record(s, label)
|
||||
self._check_led(key)
|
||||
target = s.route.get(key)
|
||||
elif s.type == "question":
|
||||
opt = s.options[int(key)]
|
||||
self._record(s, opt.label)
|
||||
target = opt.next
|
||||
else:
|
||||
return
|
||||
if target:
|
||||
self._push()
|
||||
self._go(target)
|
||||
|
||||
def proceed(self) -> None:
|
||||
"""Continue on info steps; 'skip waiting' on wait steps."""
|
||||
s = self.step
|
||||
if s is None:
|
||||
return
|
||||
if s.type == "info" and s.next:
|
||||
self._push()
|
||||
self._go(s.next)
|
||||
elif s.type == "wait":
|
||||
self._record(s, "technician skipped the wait")
|
||||
self._finish_wait("timeout")
|
||||
|
||||
def back(self) -> None:
|
||||
# Never step back into a wait: re-entering it could restart the box.
|
||||
while self._stack:
|
||||
gid, sid = self._stack.pop()
|
||||
if self.guides[gid].steps[sid].type != "wait":
|
||||
self.guide, self.end, self.note = self.guides[gid], None, ""
|
||||
self._enter_step(sid)
|
||||
return
|
||||
|
||||
# --------------------------------------------------------------- feed
|
||||
def feed(self, line: LogLine) -> None:
|
||||
s = self.step
|
||||
if s is None or s.type != "wait":
|
||||
return
|
||||
if s.fail_on and s.fail_on.search(line.text):
|
||||
self._record(s, f"log: {line.text}")
|
||||
self._finish_wait("fail")
|
||||
elif s.until and s.until.search(line.text):
|
||||
self._record(s, f"log: {line.text}")
|
||||
self._finish_wait("ok")
|
||||
elif s.until_milestone and self._milestone_new(s.until_milestone):
|
||||
self._record(s, f"milestone reached: {s.until_milestone}")
|
||||
self._finish_wait("ok")
|
||||
|
||||
def tick(self) -> None:
|
||||
s = self.step
|
||||
if s is not None and s.type == "wait" and self._clock() - self._wait_started >= s.timeout_s:
|
||||
self._finish_wait("ok" if s.pass_on_timeout else "timeout")
|
||||
|
||||
# --------------------------------------------------------------- view
|
||||
def view(self) -> View:
|
||||
v = View(guide=self.guide, step=self.step, end=self.end, note=self.note, can_back=bool(self._stack))
|
||||
s = self.step
|
||||
if s is None:
|
||||
return v
|
||||
v.text = self._render(s.text)
|
||||
if s.type == "led":
|
||||
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
|
||||
elif s.type == "question":
|
||||
v.options = [(str(i), o.label) for i, o in enumerate(s.options)]
|
||||
elif s.type == "wait":
|
||||
v.waiting = True
|
||||
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
|
||||
return v
|
||||
|
||||
# ---------------------------------------------------------- internals
|
||||
def _push(self) -> None:
|
||||
if self.guide and self.step:
|
||||
self._stack.append((self.guide.id, self.step.id))
|
||||
|
||||
def _enter_guide(self, guide_id: str) -> None:
|
||||
self.guide = self.guides[guide_id]
|
||||
self._on_log(f"technician: started guide '{self.guide.title}'")
|
||||
self._enter_step(self.guide.start)
|
||||
|
||||
def _go(self, target: str) -> None:
|
||||
kind, _, ref = target.partition(":")
|
||||
if kind == "end" and ref:
|
||||
self._conclude(self.guide.ends[ref])
|
||||
elif kind == "guide" and ref:
|
||||
self._enter_guide(ref)
|
||||
else:
|
||||
self._enter_step(target)
|
||||
|
||||
def _enter_step(self, step_id: str) -> None:
|
||||
s = self.guide.steps[step_id]
|
||||
self.step, self.note = s, ""
|
||||
if s.skip_if_finding and self._finding_active(s.skip_if_finding):
|
||||
self._auto(s, f"skipped - log already shows {s.skip_if_finding}", s.skip_to)
|
||||
return
|
||||
if s.skip_unless_feature and not self._feature(s.skip_unless_feature):
|
||||
self._auto(s, f"skipped - firmware has no {s.skip_unless_feature}", s.skip_to)
|
||||
return
|
||||
if s.type == "route":
|
||||
target = self._route(s)
|
||||
self._auto(s, f"routed by log findings -> {target}", target)
|
||||
return
|
||||
if s.type == "wait":
|
||||
self._wait_started = self._clock()
|
||||
self._wait_boot = self.engine.boot.index
|
||||
self._wait_milestones = set(self.engine.milestones)
|
||||
if s.tool_action and not self._on_action(s.tool_action):
|
||||
self.note = "Could not restart the box from the tool - connect to it first, or switch it off and on."
|
||||
# A milestone that is already reached satisfies the wait at once
|
||||
# unless the step first restarts the box.
|
||||
if s.until_milestone and not s.tool_action and s.until_milestone in self.engine.milestones:
|
||||
self._record(s, f"already reached: {s.until_milestone}")
|
||||
self._finish_wait("ok")
|
||||
|
||||
def _auto(self, s: Step, why: str, target: str | None) -> None:
|
||||
self._on_log(f"technician guide: {s.id}: {why}")
|
||||
if target:
|
||||
self._go(target)
|
||||
|
||||
def _finish_wait(self, outcome: str) -> None:
|
||||
s = self.step
|
||||
target = {"ok": s.on_ok, "fail": s.on_fail or s.on_timeout, "timeout": s.on_timeout or s.on_fail}[outcome]
|
||||
if target is None:
|
||||
return
|
||||
self._push()
|
||||
self._go(target)
|
||||
|
||||
def _milestone_new(self, mid: str) -> bool:
|
||||
if mid not in self.engine.milestones:
|
||||
return False
|
||||
return self.engine.boot.index != self._wait_boot or mid not in self._wait_milestones
|
||||
|
||||
def _route(self, s: Step) -> str:
|
||||
report = self.engine.report()
|
||||
for rc in report.roots:
|
||||
for f in (rc.finding, *rc.consequences):
|
||||
if f.rule_id in s.routes:
|
||||
return s.routes[f.rule_id]
|
||||
return s.default or "end:" + next(iter(self.guide.ends))
|
||||
|
||||
def _check_led(self, seen: str) -> None:
|
||||
expected = self.engine.led
|
||||
if expected in ("UNKNOWN", "OFF") or seen == "CYCLING" or seen == expected:
|
||||
return
|
||||
exp_label, see_label = LED[expected][0], LED_ANSWER_LABELS[seen]
|
||||
self.engine.add_confirmed(
|
||||
"tech.led_mismatch", Severity.WARNING,
|
||||
f"LED shows {see_label}, but the log implies {exp_label}",
|
||||
f"The firmware state in the log corresponds to {exp_label}, while the technician sees {see_label}. "
|
||||
"Either the LED just changed (check again in a few seconds), the LED/its wiring is faulty, "
|
||||
"or the box runs different firmware. Trust the log findings.",
|
||||
("Look at the LED again for 10 seconds.", "If it stays different: note it for repair (LED board)."))
|
||||
|
||||
def _conclude(self, end: End) -> None:
|
||||
self.step, self.end = None, end
|
||||
self._on_log(f"technician guide result: {end.title}")
|
||||
sev = END_SEVERITY[end.severity]
|
||||
self.engine.note_technician(f"{self.guide.title}: {end.title}")
|
||||
if sev is not None:
|
||||
self.engine.add_confirmed(f"tech.{self.guide.id}.{end.id}", sev, end.title, end.text, end.actions)
|
||||
|
||||
def _record(self, s: Step, answer: str) -> None:
|
||||
q = self._render(s.text)
|
||||
self.engine.note_technician(f"{q} -> {answer}")
|
||||
self._on_log(f"technician: {q[:80]} -> {answer}")
|
||||
|
||||
def _finding_active(self, rule_id: str) -> bool:
|
||||
return any(f.rule_id == rule_id and f.active for f in self.engine.findings.values())
|
||||
|
||||
def _feature(self, name: str) -> bool:
|
||||
fw = self.engine.firmware
|
||||
if name == "sd_wifi":
|
||||
return fw.supports_sd_wifi is not False
|
||||
if name == "setup_portal":
|
||||
if fw.known is not None:
|
||||
return fw.known.setup_portal
|
||||
if "setup_portal" in fw.evidence:
|
||||
return True
|
||||
return "legacy_v1" not in fw.evidence # unknown firmware: ask
|
||||
return False
|
||||
|
||||
def _render(self, text: str) -> str:
|
||||
report = self.engine.report()
|
||||
sensors = sorted({f.title.removesuffix(" failed to start") for f in self.engine.findings.values()
|
||||
if f.rule_id == "sensor.init_failed" and f.active})
|
||||
ctx = {
|
||||
"expected_led": LED[report.led][0],
|
||||
"failing_sensors": ", ".join(sensors) or "see the Diagnosis tab",
|
||||
"top_finding": report.roots[0].finding.title if report.roots else "none",
|
||||
}
|
||||
return text.format_map(_Fmt(ctx))
|
||||
|
||||
|
||||
class _Fmt(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
+53
-28
@@ -33,7 +33,7 @@ start = "led"
|
||||
id = "led"
|
||||
type = "led"
|
||||
text = "Look at the RGB status LED on the box (no need to open it). Which colour is it?"
|
||||
route = { GREEN = "heartbeat", PURPLE = "guide:network", RED = "guide:sensors", BLUE = "guide:rtc_i2c", WHITE = "guide:white", YELLOW = "guide:update", ORANGE = "guide:update", OFF = "guide:power", CYCLING = "guide:reboot_loop" }
|
||||
route = { GREEN = "heartbeat", PURPLE = "guide:network", RED = "guide:sensors", BLUE = "guide:rtc_i2c", WHITE = "guide:white", YELLOW = "guide:update", ORANGE = "guide:update", CYAN = "guide:network", OFF = "guide:power", CYCLING = "guide:reboot_loop" }
|
||||
|
||||
[[guide.step]]
|
||||
id = "heartbeat"
|
||||
@@ -64,7 +64,7 @@ title = "Firmware is not running normally"
|
||||
text = "The heartbeat LED should blink once per second whenever the firmware runs. A steady heartbeat LED means the firmware has frozen."
|
||||
actions = [
|
||||
"Switch the box off and on with the power switch.",
|
||||
"If it happens again: save the session log and re-flash the official firmware (Flash tab, coming in v0.4).",
|
||||
"If it happens again: save the session log and re-flash the official firmware (Flash firmware tab, App only).",
|
||||
]
|
||||
|
||||
# =============================================================== network
|
||||
@@ -72,7 +72,8 @@ actions = [
|
||||
id = "network"
|
||||
title = "Purple LED / no Internet"
|
||||
summary = "Pairing in Matter, site Wi-Fi and Internet checks, with live confirmation."
|
||||
recommend = ["led:PURPLE", "matter.not_commissioned", "net.dns_fail", "influx.buffer_fail", "ota.registration_failed"]
|
||||
recommend = ["led:PURPLE", "led:CYAN", "matter.not_commissioned", "net.dns_fail", "influx.buffer_fail", "ota.registration_failed",
|
||||
"status.conn", "status.conn.1", "kota.wifi_down", "kota.no_upload", "kota.weak_wifi", "kota.ota_unregistered"]
|
||||
start = "paired"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -113,7 +114,8 @@ next = "pair_start"
|
||||
[[guide.step]]
|
||||
id = "pair_start"
|
||||
type = "wait"
|
||||
text = "In the Matter app choose 'Add device' and scan the QR code on the sticker (or type the 11-digit code printed under it). Keep the phone within 1 m of the box."
|
||||
text = "In the Matter app choose 'Add device' and scan the QR code shown here (the same as the sticker), or type the 11-digit manual code. Keep the phone within 1 m of the box."
|
||||
show_qr = true
|
||||
until = 'MAIN: Commissioning session started'
|
||||
timeout_s = 300
|
||||
on_ok = "pair_wifi"
|
||||
@@ -248,7 +250,7 @@ actions = ["Keep the tool connected a few more minutes and watch the Diagnosis t
|
||||
id = "rtc_i2c"
|
||||
title = "Blue LED - clock / I2C cable"
|
||||
summary = "Re-seat the I2C cable and verify the start-up live."
|
||||
recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors"]
|
||||
recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors", "kota.i2c_stuck", "kota.rtc_read_failed", "status.rtc.1", "status.rtc.2"]
|
||||
start = "explain"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -309,7 +311,8 @@ actions = ["Replace the RTC module, or send the box for repair with the session
|
||||
id = "white"
|
||||
title = "White LED - SD card / reset / setup mode"
|
||||
summary = "Tell apart the three white states and check the SD card."
|
||||
recommend = ["led:WHITE", "sd.card_fail", "sd.mount_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail"]
|
||||
recommend = ["led:WHITE", "sd.card_fail", "sd.mount_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail",
|
||||
"kota.sd_unmounted", "status.sd", "status.portal", "status.reset"]
|
||||
start = "button"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -379,7 +382,7 @@ severity = "critical"
|
||||
title = "SD card is unreadable or has wrong settings"
|
||||
text = "Even after re-seating, the card or its ENV.TXT cannot be used."
|
||||
actions = [
|
||||
"Re-provision the card with the tool (SD tab, coming in v0.5) or use a new card.",
|
||||
"Re-provision the card with the tool (SD card tab) or use a new card.",
|
||||
"The Diagnosis tab shows which step failed (card, file system or ENV.TXT keys).",
|
||||
]
|
||||
|
||||
@@ -388,7 +391,9 @@ actions = [
|
||||
id = "sensors"
|
||||
title = "Red LED - sensor or hardware fault"
|
||||
summary = "Find the failing sensor and verify after re-seating its cable."
|
||||
recommend = ["led:RED", "sensor.init_failed", "sensor.selfcheck_failed", "sys.out_of_resources", "ota.selfcheck_failed"]
|
||||
recommend = ["led:RED", "sensor.init_failed", "sensor.selfcheck_failed", "sys.out_of_resources", "ota.selfcheck_failed",
|
||||
"kota.sensor_missing", "status.error", "status.error.1", "status.error.2", "status.error.3", "status.error.4",
|
||||
"status.error.5", "status.error.6", "status.oom"]
|
||||
start = "which"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -469,7 +474,8 @@ actions = ["Save the session log and send it to the firmware team."]
|
||||
id = "reboot_loop"
|
||||
title = "LED keeps changing / box restarts"
|
||||
summary = "Record several start-ups and find the error just before each restart."
|
||||
recommend = ["led:CYCLING", "reboot.loop", "sys.crash", "reboot.unexpected", "reboot.brownout", "reboot.watchdog"]
|
||||
recommend = ["led:CYCLING", "reboot.loop", "sys.crash", "reboot.unexpected", "reboot.brownout", "reboot.watchdog",
|
||||
"reboot.crash", "reboot.status_watchdog", "reboot.status_oom_watchdog"]
|
||||
start = "record"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -597,7 +603,7 @@ title = "Box does not start"
|
||||
text = "Neither LED is lit: no power or the firmware does not start."
|
||||
actions = [
|
||||
"Check the power supply and cable.",
|
||||
"If the USB port shows up in this tool, re-flash the official firmware (Flash tab, coming in v0.4).",
|
||||
"If the USB port shows up in this tool, re-flash the official firmware (Flash firmware tab, App only).",
|
||||
]
|
||||
|
||||
# =================================================================== clock
|
||||
@@ -605,7 +611,7 @@ actions = [
|
||||
id = "clock"
|
||||
title = "Clock not set"
|
||||
summary = "Why readings are on hold and when the coin cell matters."
|
||||
recommend = ["rtc.clock_not_running", "rtc.lost_after_sync"]
|
||||
recommend = ["rtc.clock_not_running", "rtc.lost_after_sync", "kota.rtc_stopped", "kota.rtc_not_ticking", "kota.time_invalid"]
|
||||
start = "internet"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -651,29 +657,48 @@ default = "clap"
|
||||
[[guide.step]]
|
||||
id = "switch"
|
||||
type = "question"
|
||||
text = "The firmware reads the microphone as switched off. Does this box have a microphone ON/OFF switch or jumper (inside the case)?"
|
||||
text = "The box reads its microphone switch as OFF. The switch is on the back of the case, just above the power switch (no need to open the box). Should this box measure noise?"
|
||||
options = [
|
||||
{ label = "Yes - I set it to ON", next = "switch_verify" },
|
||||
{ label = "No such switch / not sure", next = "end:mic_enable_line" },
|
||||
{ label = "It is meant to be off", next = "end:mic_off_ok" },
|
||||
{ label = "Yes - I flipped the microphone switch", next = "switch_verify" },
|
||||
{ label = "No - it is meant to be off", next = "end:mic_off_ok" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "switch_verify"
|
||||
type = "wait"
|
||||
text = "The tool restarts the box and waits for a microphone reading (up to 60 s)..."
|
||||
tool_action = "reset"
|
||||
# The firmware re-reads the switch before every reading (~10 s): no restart needed.
|
||||
text = "Waiting for the next microphone reading (up to 30 s)..."
|
||||
until = 'MICROPHONE: Read \d+ bytes'
|
||||
fail_on = 'Microphone is in shutdown state'
|
||||
timeout_s = 60
|
||||
timeout_s = 30
|
||||
on_ok = "end:mic_on"
|
||||
on_fail = "end:mic_enable_line"
|
||||
on_timeout = "end:mic_enable_line"
|
||||
on_fail = "flip_back"
|
||||
on_timeout = "flip_back"
|
||||
|
||||
[[guide.step]]
|
||||
id = "flip_back"
|
||||
type = "question"
|
||||
text = "The box still reads the switch as OFF. Flip the microphone switch to the other position (it may have been ON already)."
|
||||
options = [
|
||||
{ label = "Done - I flipped it to the other position", next = "switch_verify2" },
|
||||
{ label = "Leave it off", next = "end:mic_off_ok" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "switch_verify2"
|
||||
type = "wait"
|
||||
text = "Waiting for the next microphone reading (up to 30 s)..."
|
||||
until = 'MICROPHONE: Read \d+ bytes'
|
||||
fail_on = 'Microphone is in shutdown state'
|
||||
timeout_s = 30
|
||||
on_ok = "end:mic_on"
|
||||
on_fail = "end:mic_switch_faulty"
|
||||
on_timeout = "end:mic_switch_faulty"
|
||||
|
||||
[[guide.step]]
|
||||
id = "clap"
|
||||
type = "wait"
|
||||
text = "Clap your hands or speak loudly right next to the box for about 20 seconds..."
|
||||
text = "Check the microphone switch (back of the case, above the power switch) is ON. Then clap your hands or speak loudly right next to the box for about 20 seconds..."
|
||||
until = 'dB peak: (?!-inf)'
|
||||
timeout_s = 45
|
||||
on_ok = "end:mic_ok"
|
||||
@@ -683,8 +708,8 @@ on_timeout = "end:mic_faulty"
|
||||
id = "mic_on"
|
||||
severity = "ok"
|
||||
title = "Microphone is on and recording"
|
||||
text = "The firmware now reads audio samples."
|
||||
actions = []
|
||||
text = "The firmware reads audio samples again."
|
||||
actions = ["Leave the microphone switch in this position."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "mic_off_ok"
|
||||
@@ -694,11 +719,11 @@ text = "No noise data is recorded for this box; everything else works."
|
||||
actions = []
|
||||
|
||||
[[guide.end]]
|
||||
id = "mic_enable_line"
|
||||
id = "mic_switch_faulty"
|
||||
severity = "warning"
|
||||
title = "Microphone enable line stays 'off'"
|
||||
text = "The firmware keeps reading the microphone as switched off (GPIO3)."
|
||||
actions = ["Check the microphone board and its cable/connector (inside the case); send the box MAC to the firmware team to confirm the hardware variant."]
|
||||
title = "Microphone switch has no effect"
|
||||
text = "The box reads the microphone as OFF in both switch positions (enable line GPIO3)."
|
||||
actions = ["Check the switch and its cable to the main board (inside the case); replace the switch if needed."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "mic_ok"
|
||||
@@ -711,5 +736,5 @@ actions = []
|
||||
id = "mic_faulty"
|
||||
severity = "error"
|
||||
title = "Microphone records nothing"
|
||||
text = "Even loud noise next to the box gives no signal."
|
||||
text = "Even loud noise next to the box gives no signal, although the switch is on."
|
||||
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""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",
|
||||
"usb_wifi_set", "usb_wifi_clear", # Wi-Fi changed from the technician tool (usb_console)
|
||||
}
|
||||
|
||||
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
|
||||
+732
-572
File diff suppressed because it is too large.
Load diff
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())
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Commands to the box over the USB serial port (firmware with usb_console, api=1).
|
||||
|
||||
Protocol (kota-sensorboard main/usb_console.hpp): ASCII lines "kota ...",
|
||||
one reply line each, logged as "KOTA: cmd <name> ok=1|0 [key=value ...]":
|
||||
|
||||
kota ping -> cmd ping ok=1 fw=<version> api=1
|
||||
kota health -> cmd health ok=1 <health fields> | ok=0 err=not_ready
|
||||
kota wifi get -> cmd wifi_get ok=1 ssid=<hex|-> password_set=0|1 mode=...
|
||||
kota wifi set <ssid_hex> <pass_hex> -> cmd wifi_set ok=1 restart=1, then restart (usb_wifi_set)
|
||||
kota wifi clear -> cmd wifi_clear ok=1 restart=1, then restart (usb_wifi_clear)
|
||||
|
||||
Firmware without the console never answers. The password is only ever sent
|
||||
hex-encoded and is never written to the log by the box or by this tool.
|
||||
|
||||
WifiFlow runs a whole "write / clear Wi-Fi" job as a small state machine
|
||||
driven by `feed(line)` and `tick(now)`, so it is testable without Qt or a box.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..core.logline import LineKind, LogLine
|
||||
|
||||
API_VERSION = 1
|
||||
|
||||
# Technician-facing text for the firmware's err= codes.
|
||||
ERRORS = {
|
||||
"bad_args": "The box did not understand the command (tool/firmware mismatch).",
|
||||
"bad_hex": "The box could not decode the settings (tool/firmware mismatch).",
|
||||
"bad_char": "The name or password contains a line break.",
|
||||
"ssid_length": "The network name must be 1-32 bytes.",
|
||||
"password_length": "The password must be 8-63 characters.",
|
||||
"sd_not_mounted": "The box cannot read its SD card, so it cannot store the settings.",
|
||||
"write_failed": "Writing ENV.TXT on the SD card failed.",
|
||||
"verify_failed": "ENV.TXT did not read back correctly after writing.",
|
||||
"unknown_command": "This firmware does not know the command.",
|
||||
"not_ready": "The box is still starting; try again in a few seconds.",
|
||||
}
|
||||
|
||||
|
||||
def hex_utf8(text: str) -> str:
|
||||
return text.encode("utf-8").hex()
|
||||
|
||||
|
||||
def unhex_utf8(value: str) -> str | None:
|
||||
if not value or value == "-":
|
||||
return None
|
||||
try:
|
||||
return bytes.fromhex(value).decode("utf-8", errors="replace")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_wifi(ssid: str, password: str) -> list[str]:
|
||||
"""Same limits as the firmware (usb_console.cpp); empty list = OK."""
|
||||
problems = []
|
||||
if not ssid:
|
||||
problems.append("Enter the Wi-Fi network name (SSID).")
|
||||
elif len(ssid.encode("utf-8")) > 32:
|
||||
problems.append("The network name is longer than 32 bytes.")
|
||||
if not 8 <= len(password.encode("utf-8")) <= 63:
|
||||
problems.append("The password must be 8-63 characters (WPA2). Open networks are not supported.")
|
||||
if any(c in ssid + password for c in "\r\n\0"):
|
||||
problems.append("The name or password contains a line break.")
|
||||
return problems
|
||||
|
||||
|
||||
def wifi_set_command(ssid: str, password: str) -> str:
|
||||
problems = validate_wifi(ssid, password)
|
||||
if problems:
|
||||
raise ValueError(" ".join(problems))
|
||||
return f"kota wifi set {hex_utf8(ssid)} {hex_utf8(password)}"
|
||||
|
||||
|
||||
def loggable(command: str) -> str:
|
||||
"""The command as it may appear in the session log: never the password."""
|
||||
parts = command.split()
|
||||
if parts[:3] == ["kota", "wifi", "set"] and len(parts) == 5:
|
||||
return f"kota wifi set (network '{unhex_utf8(parts[3])}', password hidden)"
|
||||
return command
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reply:
|
||||
name: str # ping | health | wifi_get | wifi_set | wifi_clear | unknown
|
||||
ok: bool
|
||||
fields: dict[str, str]
|
||||
|
||||
@property
|
||||
def error(self) -> str:
|
||||
code = self.fields.get("err", "")
|
||||
return ERRORS.get(code, f"The box reported an error ({code or 'unknown'}).")
|
||||
|
||||
|
||||
def parse_reply(line: LogLine) -> Reply | None:
|
||||
"""'I (5) KOTA: cmd wifi_set ok=1 restart=1' -> Reply('wifi_set', True, {...})."""
|
||||
if line.kind != LineKind.KOTA or line.record != "cmd" or not line.words:
|
||||
return None
|
||||
return Reply(line.words[0], line.fields.get("ok") == "1", dict(line.fields))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WifiInfo:
|
||||
"""What `kota wifi get` reported."""
|
||||
ssid: str | None
|
||||
password_set: bool
|
||||
mode: str
|
||||
|
||||
@classmethod
|
||||
def from_reply(cls, r: Reply) -> WifiInfo:
|
||||
return cls(unhex_utf8(r.fields.get("ssid", "-")), r.fields.get("password_set") == "1",
|
||||
r.fields.get("mode", "?"))
|
||||
|
||||
def describe(self) -> str:
|
||||
mode = {"wifi": "local Wi-Fi from the SD card", "matter": "Matter (Wi-Fi given by the Matter app)",
|
||||
"provisioning": "Wi-Fi setup portal"}.get(self.mode, self.mode)
|
||||
net = f"network '{self.ssid}'" + (", password set" if self.password_set else ", NO password")
|
||||
return f"Mode: {mode}. " + (net if self.ssid else "No network stored on the SD card.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ flows
|
||||
|
||||
class Step(str, enum.Enum):
|
||||
PING = "Checking the box accepts USB commands"
|
||||
READ = "Reading the Wi-Fi settings"
|
||||
SEND = "Storing the settings on the box's SD card"
|
||||
RESTART = "Waiting for the box to restart"
|
||||
CONNECT = "Waiting for the box to join the Wi-Fi network"
|
||||
DONE = "Done"
|
||||
FAILED = "Failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowEvent:
|
||||
step: Step
|
||||
text: str
|
||||
ok: bool | None = None # None = in progress, True = finished OK, False = failed
|
||||
|
||||
|
||||
PING_TIMEOUT_S = 3.0
|
||||
REPLY_TIMEOUT_S = 8.0
|
||||
RESTART_TIMEOUT_S = 40.0
|
||||
CONNECT_TIMEOUT_S = 90.0
|
||||
HEALTH_POLL_S = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class WifiFlow:
|
||||
"""One technician job: "read", "set" (ssid+password) or "clear".
|
||||
|
||||
`send(text)` writes one command line to the box (returns False when the
|
||||
port is not connected); `on_event` reports progress for the UI.
|
||||
"""
|
||||
action: str
|
||||
send: Callable[[str], bool]
|
||||
on_event: Callable[[FlowEvent], None]
|
||||
ssid: str = ""
|
||||
password: str = ""
|
||||
now: Callable[[], float] = time.monotonic
|
||||
step: Step = Step.PING
|
||||
info: WifiInfo | None = None
|
||||
_deadline: float = 0.0
|
||||
_next_poll: float = 0.0
|
||||
_events: list[FlowEvent] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self.step in (Step.DONE, Step.FAILED)
|
||||
|
||||
def start(self) -> None:
|
||||
if self.action == "set":
|
||||
problems = validate_wifi(self.ssid, self.password)
|
||||
if problems:
|
||||
return self._fail(" ".join(problems))
|
||||
self._go(Step.PING, "kota ping", PING_TIMEOUT_S)
|
||||
|
||||
def cancel(self) -> None:
|
||||
if not self.finished:
|
||||
self._fail("Cancelled.")
|
||||
|
||||
# -------------------------------------------------------- driving
|
||||
def feed(self, line: LogLine) -> None:
|
||||
if self.finished:
|
||||
return
|
||||
if self.step == Step.RESTART and line.kind == LineKind.KOTA and line.record == "boot":
|
||||
return self._on_boot(line.fields)
|
||||
r = parse_reply(line)
|
||||
if r is None:
|
||||
return
|
||||
if self.step == Step.PING and r.name == "ping":
|
||||
if not r.ok or int(r.fields.get("api", "0") or 0) < API_VERSION:
|
||||
return self._fail("The box answered, but its command version is not supported.")
|
||||
self._emit(f"Firmware {r.fields.get('fw', '?')} accepts USB commands.", True)
|
||||
if self.action == "read":
|
||||
return self._go(Step.READ, "kota wifi get", REPLY_TIMEOUT_S)
|
||||
if self.action == "set":
|
||||
return self._go(Step.SEND, wifi_set_command(self.ssid, self.password), REPLY_TIMEOUT_S)
|
||||
return self._go(Step.SEND, "kota wifi clear", REPLY_TIMEOUT_S)
|
||||
if self.step == Step.READ and r.name == "wifi_get":
|
||||
if not r.ok:
|
||||
return self._fail(r.error)
|
||||
self.info = WifiInfo.from_reply(r)
|
||||
return self._finish(self.info.describe())
|
||||
if self.step == Step.SEND and r.name in ("wifi_set", "wifi_clear"):
|
||||
if not r.ok:
|
||||
return self._fail(r.error)
|
||||
self._emit("The box stored the settings and is restarting.", True)
|
||||
self.step, self._deadline = Step.RESTART, self.now() + RESTART_TIMEOUT_S
|
||||
return self._emit(Step.RESTART.value + "...")
|
||||
if self.step == Step.CONNECT and r.name == "health" and r.ok:
|
||||
return self._on_health(r.fields)
|
||||
|
||||
def tick(self) -> None:
|
||||
"""Call periodically (e.g. every 0.5 s): timeouts and health polling."""
|
||||
if self.finished:
|
||||
return
|
||||
t = self.now()
|
||||
if self.step == Step.CONNECT and t >= self._next_poll:
|
||||
self._next_poll = t + HEALTH_POLL_S
|
||||
self.send("kota health")
|
||||
if t < self._deadline:
|
||||
return
|
||||
if self.step == Step.PING:
|
||||
self._fail("The box does not answer USB commands. It needs firmware with the USB console "
|
||||
"(Flash firmware tab), or it is still starting - try again in 10 s.")
|
||||
elif self.step == Step.RESTART:
|
||||
self._fail("The box stored the settings but no restart was seen. Press Reset & capture in the "
|
||||
"Serial monitor and check the Diagnosis panel.")
|
||||
elif self.step == Step.CONNECT:
|
||||
self._fail(f"The box did not join '{self.ssid}' within {int(CONNECT_TIMEOUT_S)} s. Check the network "
|
||||
"name and password, that it is a 2.4 GHz network and that it is in range. The settings stay "
|
||||
"on the box; it keeps retrying.")
|
||||
else:
|
||||
self._fail("The box did not answer. Check the USB connection and try again.")
|
||||
|
||||
# -------------------------------------------------------- internals
|
||||
def _on_boot(self, f: dict[str, str]) -> None:
|
||||
mode, last = f.get("mode", "?"), f.get("last_restart", "?")
|
||||
want = "usb_wifi_set" if self.action == "set" else "usb_wifi_clear"
|
||||
if last != want:
|
||||
return self._fail(f"The box restarted for another reason ({last}); the settings may not be applied.")
|
||||
self._emit(f"Restarted in {mode} mode.", True)
|
||||
if self.action == "clear":
|
||||
ok = mode == "matter"
|
||||
return self._finish("Wi-Fi removed; the box uses Matter again (pair it in the Matter app if needed).") \
|
||||
if ok else self._fail(f"The box restarted in {mode} mode instead of Matter.")
|
||||
if mode != "wifi":
|
||||
return self._fail(f"The box restarted in {mode} mode instead of local Wi-Fi.")
|
||||
self.step = Step.CONNECT
|
||||
self._deadline = self.now() + CONNECT_TIMEOUT_S
|
||||
self._next_poll = self.now() + HEALTH_POLL_S
|
||||
self._emit(Step.CONNECT.value + f" '{self.ssid}'...")
|
||||
|
||||
def _on_health(self, f: dict[str, str]) -> None:
|
||||
if f.get("wifi") != "connected":
|
||||
return
|
||||
rssi = f.get("rssi", "?")
|
||||
note = " - weak signal, consider moving the box or the router" if rssi.lstrip("-").isdigit() \
|
||||
and int(rssi) < -75 else ""
|
||||
self._finish(f"Connected to '{self.ssid}' (signal {rssi} dBm{note}). Data upload starts within a minute.")
|
||||
|
||||
def _go(self, step: Step, command: str, timeout: float) -> None:
|
||||
self.step, self._deadline = step, self.now() + timeout
|
||||
self._emit(step.value + "...")
|
||||
if not self.send(command):
|
||||
self._fail("The box is not connected. Connect it first.")
|
||||
|
||||
def _emit(self, text: str, ok: bool | None = None) -> None:
|
||||
ev = FlowEvent(self.step, text, ok)
|
||||
self._events.append(ev)
|
||||
self.on_event(ev)
|
||||
|
||||
def _finish(self, text: str) -> None:
|
||||
self.step = Step.DONE
|
||||
self._emit(text, True)
|
||||
|
||||
def _fail(self, text: str) -> None:
|
||||
self.step = Step.FAILED
|
||||
self._emit(text, False)
|
||||
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"))
|
||||
+39
-36
@@ -1,52 +1,55 @@
|
||||
"""Quick-start cheat sheet shown on launch.
|
||||
|
||||
Targets the DEPLOYED firmware: new_system.bin, app "kota-sensorboard"
|
||||
1.0, built Jun 29 2026 11:03:32, ELF SHA256 45fa9d5b9... (verified on a
|
||||
live board). That build has no Wi-Fi setup portal / SSID-on-SD support;
|
||||
those exist only in the newer, unbuilt source. The status LED is always
|
||||
SOLID; the separate small LED toggles every second while alive.
|
||||
LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md,
|
||||
status.cpp Status::pattern_for): colour AND pattern name the fault. Older
|
||||
boxes (v1.0, built Jun 29 2026) show the same colours, always solid.
|
||||
"""
|
||||
|
||||
# (swatch colour, LED name, meaning, first action)
|
||||
# (swatch colour, LED name, pattern -> meaning, first action)
|
||||
LED_TABLE = [
|
||||
("#2e9e44", "Green", "Everything OK.", "Nothing to do."),
|
||||
("#8e44ad", "Purple",
|
||||
"No Internet / InfluxDB or OTA server unreachable.",
|
||||
"Check the box is commissioned in the Matter app (sticker QR code) and the "
|
||||
"site Wi-Fi has Internet. Board reboots itself after ~2.5 min of purple."),
|
||||
("#d64541", "Red",
|
||||
"Sensor or hardware fault (BME688, SEN66, AS7341, microphone, flicker) "
|
||||
"or failed self-check after update.",
|
||||
"Connect serial - the log names the failing sensor. Check sensor cables."),
|
||||
("#2f6fd6", "Blue",
|
||||
"Clock (RTC DS1307) failed to start. The RTC is the first I2C device, "
|
||||
"so a broken I2C cable also shows blue.",
|
||||
"Check the I2C harness first, then the RTC module / coin cell."),
|
||||
("#e6c229", "Yellow", "Firmware update downloading.", "Do not switch off. Wait."),
|
||||
("#f08c1a", "Orange", "Self-check after an update (~2.5 min).",
|
||||
"Wait; should turn green (or red on failure)."),
|
||||
("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."),
|
||||
("#1fc8d8", "Cyan",
|
||||
"breathing: not paired in Matter yet<br>fast blink: a phone is connected for pairing.",
|
||||
"Pair the box in the Matter app (QR sticker or the QR code in the tool)."),
|
||||
("#f4f4f4", "White",
|
||||
"SD card missing/unreadable or ENV.TXT incomplete - or a factory reset is "
|
||||
"in progress (Matter button held 5 s).",
|
||||
"If nobody pressed the Matter button: the SD card is inside the case - "
|
||||
"unscrew, re-seat or re-provision it."),
|
||||
"fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>"
|
||||
"breathing: Wi-Fi setup portal.",
|
||||
"SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."),
|
||||
("#d64541", "Red",
|
||||
"1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed<br>"
|
||||
"solid: other internal error / out of memory.",
|
||||
"Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."),
|
||||
("#2f6fd6", "Blue",
|
||||
"1 pulse: clock (RTC) not responding<br>2 pulses: I2C bus stuck (shorted / broken sensor cable).",
|
||||
"Check the I2C cable between the boards first, then the RTC module."),
|
||||
("#8e44ad", "Purple",
|
||||
"solid: no network<br>blinking: server rejected the box (wrong ENV.TXT credentials).",
|
||||
"Solid: check Wi-Fi / Internet. Blinking: re-provision the SD card with the current ENV.TXT."),
|
||||
("#e6c229", "Yellow", "blinking, faster over time: downloading an update.", "Do not switch off. Wait."),
|
||||
("#f08c1a", "Orange", "solid: self-check after an update (~2.5 min).",
|
||||
"Wait; should turn green (or red on failure)."),
|
||||
("#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>Colour keeps changing / LED goes dark every few seconds</b> = reboot loop. "
|
||||
"Connect serial and press <i>Reset & capture</i>: the last lines before the "
|
||||
"reboot name the cause.",
|
||||
"<b>Small LED next to the RGB LED</b> blinks once per second while the "
|
||||
"firmware runs. Not blinking = firmware hung or not running.",
|
||||
"<b>Outside the case</b>: power switch and the Matter button. Holding the "
|
||||
"<b>Before a self-restart</b> the LED flashes the error colour 3 times; <b>after the restart</b> it "
|
||||
"shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press "
|
||||
"<i>Reset & capture</i>: the tool reads the restart reason from the box.",
|
||||
"<b>Small LED next to the RGB LED</b> (heartbeat): blinks once per second while online, "
|
||||
"double-blinks while offline. Dark or steady = firmware hung or not running.",
|
||||
"<b>Serial monitor</b>: lines starting with <b>KOTA:</b> are the box's own diagnosis (boot, I2C, SD, "
|
||||
"clock, Matter, microphone, status, LED, and a health line every 60 s). The Diagnosis panel reads them "
|
||||
"directly; filter the view with <i>KOTA diagnostics + errors</i>. Error lines carry a code like "
|
||||
"<b>[E-SD-03]</b> - the tool shows the recommended action for it.",
|
||||
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
|
||||
"(off = no noise measurements), and the Matter button. Holding the "
|
||||
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
|
||||
"network and must be commissioned again with the sticker QR code).",
|
||||
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
|
||||
"Normally not needed - the tool resets the board over USB.",
|
||||
"<b>Gathering messages for the first ~2 minutes</b> are normal "
|
||||
"(the air-quality sensor warms up for 120 s).",
|
||||
"<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
|
||||
"by its compile date.",
|
||||
]
|
||||
|
||||
STEPS = [
|
||||
@@ -72,10 +75,10 @@ def cheatsheet_html() -> str:
|
||||
return f"""
|
||||
<h2>Sensorbox quick start</h2>
|
||||
<ol>{steps}</ol>
|
||||
<h3>RGB status LED (always solid colour)</h3>
|
||||
<h3>RGB status LED - colour and pattern</h3>
|
||||
<table border='1' cellspacing='0' cellpadding='0' width='100%'>
|
||||
<tr><th align='left' style='padding:6px'>LED</th>
|
||||
<th align='left' style='padding:6px'>Most likely meaning</th>
|
||||
<th align='left' style='padding:6px'>Pattern: meaning</th>
|
||||
<th align='left' style='padding:6px'>First action</th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
|
||||
+252
-230
@@ -1,230 +1,252 @@
|
||||
"""Diagnostics side panel: renders a diag.engine.Report.
|
||||
|
||||
Layout: verdict banner, expected LED, firmware, start-up 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 PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextBrowser,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diag.engine import LED, Finding, Report, Severity
|
||||
|
||||
SEV_COLOR = {
|
||||
Severity.CRITICAL: "#b03a2e",
|
||||
Severity.ERROR: "#d9534f",
|
||||
Severity.WARNING: "#c77c02",
|
||||
Severity.INFO: "#2e6da4",
|
||||
}
|
||||
# verdict banner (background, text) by verdict severity; None = healthy
|
||||
BANNER = {
|
||||
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
|
||||
Severity.ERROR: ("#f9dedc", "#8a2019"),
|
||||
Severity.WARNING: ("#fdebc8", "#6b4400"),
|
||||
None: ("#d8f0dd", "#1b5e2a"),
|
||||
}
|
||||
_KEY = Qt.ItemDataRole.UserRole
|
||||
|
||||
|
||||
def _heading(text: str) -> QLabel:
|
||||
lbl = QLabel(text)
|
||||
f = lbl.font()
|
||||
f.setBold(True)
|
||||
lbl.setFont(f)
|
||||
return lbl
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class DiagnosticsPanel(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setMinimumWidth(380)
|
||||
|
||||
self.verdict = QLabel(wordWrap=True)
|
||||
self.verdict.setMargin(10)
|
||||
vf = self.verdict.font()
|
||||
vf.setPointSize(vf.pointSize() + 2)
|
||||
vf.setBold(True)
|
||||
self.verdict.setFont(vf)
|
||||
|
||||
self.led_swatch = QLabel()
|
||||
self.led_swatch.setFixedSize(22, 22)
|
||||
self.led_text = QLabel(wordWrap=True)
|
||||
led_row = QHBoxLayout()
|
||||
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
|
||||
led_row.addWidget(self.led_text, 1)
|
||||
|
||||
self.firmware = QLabel(wordWrap=True)
|
||||
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
|
||||
self.milestones = QListWidget()
|
||||
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setIndentation(16)
|
||||
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||
self.detail = QTextBrowser()
|
||||
self.detail.setOpenExternalLinks(False)
|
||||
findings = QSplitter(Qt.Orientation.Vertical)
|
||||
findings.addWidget(self.tree)
|
||||
findings.addWidget(self.detail)
|
||||
findings.setSizes([220, 260])
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.addWidget(self.verdict)
|
||||
lay.addWidget(_heading("Status LED should be"))
|
||||
lay.addLayout(led_row)
|
||||
lay.addWidget(_heading("Firmware"))
|
||||
lay.addWidget(self.firmware)
|
||||
lay.addWidget(_heading("Start-up progress (current boot)"))
|
||||
lay.addWidget(self.milestones)
|
||||
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
||||
lay.addWidget(findings, 1)
|
||||
|
||||
self._findings: dict[str, Finding] = {}
|
||||
self._selected: str | None = None
|
||||
# key -> expanded, chosen by the user; survives the rebuild on refresh.
|
||||
self._expanded: dict[str, bool] = {}
|
||||
self.tree.currentItemChanged.connect(self._on_select)
|
||||
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
|
||||
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def show_report(self, r: Report) -> None:
|
||||
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
|
||||
if r.lines == 0:
|
||||
bg, fg = "#e6e6e6", "#333333"
|
||||
self.verdict.setText(r.verdict)
|
||||
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
|
||||
|
||||
name, colour = LED[r.led]
|
||||
self.led_swatch.setStyleSheet(
|
||||
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
|
||||
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
|
||||
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
||||
self.led_text.setText(f"<b>{name}</b>{ask}{reason}")
|
||||
|
||||
fw = r.firmware
|
||||
sd = {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"}
|
||||
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
||||
|
||||
self.milestones.clear()
|
||||
for _, label, t in r.milestones:
|
||||
done = t is not None
|
||||
it = QListWidgetItem(("✔ " if done else "○ ") + label
|
||||
+ (f" ({t / 1000:.1f} s)" if done else ""))
|
||||
it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a")))
|
||||
self.milestones.addItem(it)
|
||||
rows = min(max(self.milestones.count(), 1), 15)
|
||||
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
|
||||
|
||||
self._fill_tree(r)
|
||||
|
||||
def _fill_tree(self, r: Report) -> None:
|
||||
self.tree.blockSignals(True)
|
||||
self.tree.clear()
|
||||
self._findings = {}
|
||||
if not r.roots:
|
||||
it = QTreeWidgetItem(["No problems detected in the log so far."])
|
||||
it.setForeground(0, QBrush(QColor("#1b7a33")))
|
||||
self.tree.addTopLevelItem(it)
|
||||
for rc in r.roots:
|
||||
top = self._item(rc.finding, bold=True)
|
||||
self.tree.addTopLevelItem(top)
|
||||
for c in rc.consequences:
|
||||
top.addChild(self._item(c, prefix="↳ "))
|
||||
top.setExpanded(self._expanded.get(rc.finding.key, True))
|
||||
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
|
||||
("Resolved during this session", "grp:resolved", r.resolved)):
|
||||
if not items:
|
||||
continue
|
||||
grp = QTreeWidgetItem([f"{title} ({len(items)})"])
|
||||
grp.setData(0, _KEY, key)
|
||||
grp.setForeground(0, QBrush(QColor("#666666")))
|
||||
self.tree.addTopLevelItem(grp)
|
||||
for f in items:
|
||||
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
|
||||
grp.setExpanded(self._expanded.get(key, False))
|
||||
# Keep the selection across refreshes; default to the top root cause.
|
||||
target = self._selected if self._selected in self._findings else (
|
||||
r.roots[0].finding.key if r.roots else None)
|
||||
self.tree.blockSignals(False)
|
||||
self._select(target)
|
||||
|
||||
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
|
||||
if key := item.data(0, _KEY):
|
||||
self._expanded[key] = expanded
|
||||
|
||||
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
|
||||
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
|
||||
it.setData(0, _KEY, f.key)
|
||||
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
|
||||
it.setToolTip(0, f.title)
|
||||
if bold:
|
||||
font = it.font(0)
|
||||
font.setBold(True)
|
||||
it.setFont(0, font)
|
||||
self._findings[f.key] = f
|
||||
return it
|
||||
|
||||
def _select(self, key: str | None) -> None:
|
||||
if key is None:
|
||||
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
|
||||
return
|
||||
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
|
||||
for it in match:
|
||||
if it.data(0, _KEY) == key:
|
||||
self.tree.setCurrentItem(it)
|
||||
return
|
||||
|
||||
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
|
||||
key = cur.data(0, _KEY) if cur else None
|
||||
f = self._findings.get(key or "")
|
||||
if f is None:
|
||||
return
|
||||
self._selected = key
|
||||
self.detail.setHtml(_detail_html(f))
|
||||
|
||||
|
||||
def _detail_html(f: Finding) -> str:
|
||||
colour = SEV_COLOR.get(f.severity, "#333")
|
||||
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
||||
seen = f"Seen {f.count}x"
|
||||
if f.first_uptime_ms is not None:
|
||||
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)
|
||||
return (
|
||||
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 to do:</b></p><ol>{actions}</ol>" if actions else "")
|
||||
+ f"<p style='color:#666'>{seen}</p>"
|
||||
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||||
if evidence else "")
|
||||
)
|
||||
"""Diagnostics side panel: renders a diag.engine.Report.
|
||||
|
||||
Layout: verdict banner, expected LED (colour + pattern + meaning when the
|
||||
box reports it), firmware, the box's own self-report (KOTA lines), start-up
|
||||
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 PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextBrowser,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diag.engine import LED, Finding, Report, Severity
|
||||
|
||||
SEV_COLOR = {
|
||||
Severity.CRITICAL: "#b03a2e",
|
||||
Severity.ERROR: "#d9534f",
|
||||
Severity.WARNING: "#c77c02",
|
||||
Severity.INFO: "#2e6da4",
|
||||
}
|
||||
# verdict banner (background, text) by verdict severity; None = healthy
|
||||
BANNER = {
|
||||
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
|
||||
Severity.ERROR: ("#f9dedc", "#8a2019"),
|
||||
Severity.WARNING: ("#fdebc8", "#6b4400"),
|
||||
None: ("#d8f0dd", "#1b5e2a"),
|
||||
}
|
||||
_KEY = Qt.ItemDataRole.UserRole
|
||||
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
|
||||
|
||||
|
||||
def _heading(text: str) -> QLabel:
|
||||
lbl = QLabel(text)
|
||||
f = lbl.font()
|
||||
f.setBold(True)
|
||||
lbl.setFont(f)
|
||||
return lbl
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class DiagnosticsPanel(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setMinimumWidth(380)
|
||||
|
||||
self.verdict = QLabel(wordWrap=True)
|
||||
self.verdict.setMargin(10)
|
||||
vf = self.verdict.font()
|
||||
vf.setPointSize(vf.pointSize() + 2)
|
||||
vf.setBold(True)
|
||||
self.verdict.setFont(vf)
|
||||
|
||||
self.led_swatch = QLabel()
|
||||
self.led_swatch.setFixedSize(22, 22)
|
||||
self.led_text = QLabel(wordWrap=True)
|
||||
led_row = QHBoxLayout()
|
||||
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
|
||||
led_row.addWidget(self.led_text, 1)
|
||||
|
||||
self.firmware = QLabel(wordWrap=True)
|
||||
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
|
||||
self.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.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setIndentation(16)
|
||||
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||
self.detail = QTextBrowser()
|
||||
self.detail.setOpenExternalLinks(False)
|
||||
findings = QSplitter(Qt.Orientation.Vertical)
|
||||
findings.addWidget(self.tree)
|
||||
findings.addWidget(self.detail)
|
||||
findings.setSizes([220, 260])
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.addWidget(self.verdict)
|
||||
lay.addWidget(_heading("Status LED should be"))
|
||||
lay.addLayout(led_row)
|
||||
lay.addWidget(_heading("Firmware"))
|
||||
lay.addWidget(self.firmware)
|
||||
self.qr_btn = QPushButton("Matter pairing QR code...")
|
||||
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
|
||||
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
lay.addWidget(self.box_title)
|
||||
lay.addWidget(self.box)
|
||||
lay.addWidget(_heading("Start-up progress (current boot)"))
|
||||
lay.addWidget(self.milestones)
|
||||
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
||||
lay.addWidget(findings, 1)
|
||||
|
||||
self._findings: dict[str, Finding] = {}
|
||||
self._selected: str | None = None
|
||||
# key -> expanded, chosen by the user; survives the rebuild on refresh.
|
||||
self._expanded: dict[str, bool] = {}
|
||||
self.tree.currentItemChanged.connect(self._on_select)
|
||||
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
|
||||
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def show_report(self, r: Report) -> None:
|
||||
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
|
||||
if r.lines == 0:
|
||||
bg, fg = "#e6e6e6", "#333333"
|
||||
self.verdict.setText(r.verdict)
|
||||
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
|
||||
|
||||
name, colour = LED[r.led]
|
||||
self.led_swatch.setStyleSheet(
|
||||
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
|
||||
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 ""
|
||||
self.led_text.setText(f"<b>{name}{pattern}</b>{meaning}{source}{ask}{reason}")
|
||||
|
||||
fw = r.firmware
|
||||
sd = {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"}
|
||||
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()
|
||||
for _, label, t in r.milestones:
|
||||
done = t is not None
|
||||
it = QListWidgetItem(("✔ " if done else "○ ") + label
|
||||
+ (f" ({t / 1000:.1f} s)" if done else ""))
|
||||
it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a")))
|
||||
self.milestones.addItem(it)
|
||||
rows = min(max(self.milestones.count(), 1), 15)
|
||||
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
|
||||
|
||||
self._fill_tree(r)
|
||||
|
||||
def _fill_tree(self, r: Report) -> None:
|
||||
self.tree.blockSignals(True)
|
||||
self.tree.clear()
|
||||
self._findings = {}
|
||||
if not r.roots:
|
||||
it = QTreeWidgetItem(["No problems detected in the log so far."])
|
||||
it.setForeground(0, QBrush(QColor("#1b7a33")))
|
||||
self.tree.addTopLevelItem(it)
|
||||
for rc in r.roots:
|
||||
top = self._item(rc.finding, bold=True)
|
||||
self.tree.addTopLevelItem(top)
|
||||
for c in rc.consequences:
|
||||
top.addChild(self._item(c, prefix="↳ "))
|
||||
top.setExpanded(self._expanded.get(rc.finding.key, True))
|
||||
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
|
||||
("Resolved during this session", "grp:resolved", r.resolved)):
|
||||
if not items:
|
||||
continue
|
||||
grp = QTreeWidgetItem([f"{title} ({len(items)})"])
|
||||
grp.setData(0, _KEY, key)
|
||||
grp.setForeground(0, QBrush(QColor("#666666")))
|
||||
self.tree.addTopLevelItem(grp)
|
||||
for f in items:
|
||||
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
|
||||
grp.setExpanded(self._expanded.get(key, False))
|
||||
# Keep the selection across refreshes; default to the top root cause.
|
||||
target = self._selected if self._selected in self._findings else (
|
||||
r.roots[0].finding.key if r.roots else None)
|
||||
self.tree.blockSignals(False)
|
||||
self._select(target)
|
||||
|
||||
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
|
||||
if key := item.data(0, _KEY):
|
||||
self._expanded[key] = expanded
|
||||
|
||||
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
|
||||
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
|
||||
it.setData(0, _KEY, f.key)
|
||||
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
|
||||
it.setToolTip(0, f.title)
|
||||
if bold:
|
||||
font = it.font(0)
|
||||
font.setBold(True)
|
||||
it.setFont(0, font)
|
||||
self._findings[f.key] = f
|
||||
return it
|
||||
|
||||
def _select(self, key: str | None) -> None:
|
||||
if key is None:
|
||||
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
|
||||
return
|
||||
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
|
||||
for it in match:
|
||||
if it.data(0, _KEY) == key:
|
||||
self.tree.setCurrentItem(it)
|
||||
return
|
||||
|
||||
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
|
||||
key = cur.data(0, _KEY) if cur else None
|
||||
f = self._findings.get(key or "")
|
||||
if f is None:
|
||||
return
|
||||
self._selected = key
|
||||
self.detail.setHtml(_detail_html(f))
|
||||
|
||||
|
||||
def _detail_html(f: Finding) -> str:
|
||||
colour = SEV_COLOR.get(f.severity, "#333")
|
||||
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
||||
seen = _esc(f.seen_text())
|
||||
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 (
|
||||
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 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:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||||
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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diag.engine import LED, DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner, recommended
|
||||
|
||||
END_STYLE = { # end severity -> (border, background)
|
||||
"ok": ("#2e9e44", "#e3f4e7"),
|
||||
"info": ("#2e6da4", "#e3edf7"),
|
||||
"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)
|
||||
pm.fill(QColor(0, 0, 0, 0))
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if key == "CYCLING":
|
||||
g = QLinearGradient(0, 0, size, 0)
|
||||
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
||||
g.setColorAt(stop, QColor(c))
|
||||
p.setBrush(QBrush(g))
|
||||
else:
|
||||
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
||||
p.setPen(QColor("#777777"))
|
||||
p.drawEllipse(1, 1, size - 2, size - 2)
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
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)
|
||||
self.runner, self.engine = runner, engine
|
||||
self._shown_key: tuple | None = None
|
||||
|
||||
# --- picker (no guide running)
|
||||
self.picker = QWidget()
|
||||
pl = QVBoxLayout(self.picker)
|
||||
pl.setContentsMargins(0, 0, 0, 0)
|
||||
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
||||
self.guide_list = QListWidget()
|
||||
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
||||
pl.addWidget(self.guide_list, 1)
|
||||
self.start_btn = QPushButton("Start selected check")
|
||||
self.start_btn.clicked.connect(self._start_selected)
|
||||
pl.addWidget(self.start_btn)
|
||||
|
||||
# --- running guide
|
||||
self.card = QFrame()
|
||||
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
cl = QVBoxLayout(self.card)
|
||||
self.guide_title = QLabel()
|
||||
self.guide_title.setStyleSheet("color:#666")
|
||||
self.step_text = QLabel(wordWrap=True)
|
||||
f = self.step_text.font()
|
||||
f.setPointSize(f.pointSize() + 2)
|
||||
self.step_text.setFont(f)
|
||||
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.options = QVBoxLayout()
|
||||
self.wait_bar = QProgressBar()
|
||||
self.wait_bar.setTextVisible(False)
|
||||
self.wait_label = QLabel()
|
||||
self.note = QLabel(wordWrap=True)
|
||||
self.note.setStyleSheet("color:#b03a2e")
|
||||
self.continue_btn = QPushButton("Continue")
|
||||
self.continue_btn.clicked.connect(self._proceed)
|
||||
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
||||
self.skip_btn.clicked.connect(self._proceed)
|
||||
self.end_box = QLabel(wordWrap=True)
|
||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.end_box.setMargin(10)
|
||||
for w in (self.guide_title, self.step_text):
|
||||
cl.addWidget(w)
|
||||
cl.addLayout(self.options)
|
||||
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
||||
cl.addWidget(w)
|
||||
cl.addStretch(1)
|
||||
nav = QHBoxLayout()
|
||||
self.back_btn = QPushButton("← Back")
|
||||
self.back_btn.clicked.connect(self._back)
|
||||
self.done_btn = QPushButton("Back to checks list")
|
||||
self.done_btn.clicked.connect(self._cancel)
|
||||
nav.addWidget(self.back_btn)
|
||||
nav.addStretch(1)
|
||||
nav.addWidget(self.done_btn)
|
||||
cl.addLayout(nav)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.addWidget(self.picker, 1)
|
||||
lay.addWidget(self.card, 1)
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
def _start_selected(self) -> None:
|
||||
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
||||
if it:
|
||||
self._start(it.data(Qt.ItemDataRole.UserRole))
|
||||
|
||||
def _start(self, guide_id: str) -> None:
|
||||
self.runner.start(guide_id)
|
||||
self.refresh()
|
||||
|
||||
def _answer(self, key: str) -> None:
|
||||
self.runner.answer(key)
|
||||
self.refresh()
|
||||
|
||||
def _proceed(self) -> None:
|
||||
self.runner.proceed()
|
||||
self.refresh()
|
||||
|
||||
def _back(self) -> None:
|
||||
self.runner.back()
|
||||
self.refresh()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.runner.cancel()
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------- render
|
||||
def refresh(self) -> None:
|
||||
"""Called after every interaction and periodically (timeouts, countdown)."""
|
||||
self.runner.tick()
|
||||
v = self.runner.view()
|
||||
running = v.guide is not None
|
||||
self.picker.setVisible(not running)
|
||||
self.card.setVisible(running)
|
||||
if not running:
|
||||
self._fill_picker()
|
||||
self._shown_key = None
|
||||
return
|
||||
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
||||
if key != self._shown_key:
|
||||
self._shown_key = key
|
||||
self._render_step(v)
|
||||
if v.waiting:
|
||||
total = v.step.timeout_s
|
||||
self.wait_bar.setMaximum(int(total))
|
||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||
m, s = divmod(int(v.remaining_s), 60)
|
||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
||||
self.note.setText(v.note)
|
||||
self.note.setVisible(bool(v.note))
|
||||
self.back_btn.setEnabled(v.can_back)
|
||||
|
||||
def _fill_picker(self) -> None:
|
||||
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
||||
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
||||
if [w[0] for w in wanted] == current:
|
||||
return
|
||||
self.guide_list.clear()
|
||||
for gid, title, summary in wanted:
|
||||
it = QListWidgetItem(f"{title}\n {summary}")
|
||||
it.setData(Qt.ItemDataRole.UserRole, gid)
|
||||
self.guide_list.addItem(it)
|
||||
self.guide_list.setCurrentRow(0)
|
||||
|
||||
def _render_step(self, v) -> None:
|
||||
self.guide_title.setText(v.guide.title)
|
||||
while self.options.count():
|
||||
w = self.options.takeAt(0).widget()
|
||||
if w:
|
||||
# Detach now; deleteLater alone leaves it visible until the
|
||||
# event loop processes the deferred delete.
|
||||
w.hide()
|
||||
w.setParent(None)
|
||||
w.deleteLater()
|
||||
is_end = v.end is not None
|
||||
self.step_text.setVisible(not is_end)
|
||||
self.step_text.setText(_esc(v.text))
|
||||
for opt_key, label in v.options:
|
||||
b = QPushButton(label)
|
||||
b.setMinimumHeight(34)
|
||||
if v.step and v.step.type == "led":
|
||||
b.setIcon(_swatch(opt_key))
|
||||
b.setIconSize(QSize(22, 22))
|
||||
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
||||
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
||||
self.options.addWidget(b)
|
||||
step_type = v.step.type if v.step else None
|
||||
self.continue_btn.setVisible(step_type == "info")
|
||||
self.skip_btn.setVisible(step_type == "wait")
|
||||
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 ""))
|
||||
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diag.engine import LED, DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner, recommended
|
||||
from ..matter.payload import SetupPayload
|
||||
from .matter_qr import MatterQrWidget
|
||||
|
||||
END_STYLE = { # end severity -> (border, background)
|
||||
"ok": ("#2e9e44", "#e3f4e7"),
|
||||
"info": ("#2e6da4", "#e3edf7"),
|
||||
"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)
|
||||
pm.fill(QColor(0, 0, 0, 0))
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if key == "CYCLING":
|
||||
g = QLinearGradient(0, 0, size, 0)
|
||||
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
||||
g.setColorAt(stop, QColor(c))
|
||||
p.setBrush(QBrush(g))
|
||||
else:
|
||||
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
||||
p.setPen(QColor("#777777"))
|
||||
p.drawEllipse(1, 1, size - 2, size - 2)
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class GuidePanel(QWidget):
|
||||
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None,
|
||||
matter_payload=None):
|
||||
super().__init__(parent)
|
||||
self.runner, self.engine = runner, engine
|
||||
self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
|
||||
self._shown_key: tuple | None = None
|
||||
|
||||
# --- picker (no guide running)
|
||||
self.picker = QWidget()
|
||||
pl = QVBoxLayout(self.picker)
|
||||
pl.setContentsMargins(0, 0, 0, 0)
|
||||
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
||||
self.guide_list = QListWidget()
|
||||
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
||||
pl.addWidget(self.guide_list, 1)
|
||||
self.start_btn = QPushButton("Start selected check")
|
||||
self.start_btn.clicked.connect(self._start_selected)
|
||||
pl.addWidget(self.start_btn)
|
||||
|
||||
# --- running guide
|
||||
self.card = QFrame()
|
||||
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
cl = QVBoxLayout(self.card)
|
||||
self.guide_title = QLabel()
|
||||
self.guide_title.setStyleSheet("color:#666")
|
||||
self.step_text = QLabel(wordWrap=True)
|
||||
f = self.step_text.font()
|
||||
f.setPointSize(f.pointSize() + 2)
|
||||
self.step_text.setFont(f)
|
||||
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.options = QVBoxLayout()
|
||||
self.wait_bar = QProgressBar()
|
||||
self.wait_bar.setTextVisible(False)
|
||||
self.wait_label = QLabel()
|
||||
self.note = QLabel(wordWrap=True)
|
||||
self.note.setStyleSheet("color:#b03a2e")
|
||||
self.continue_btn = QPushButton("Continue")
|
||||
self.continue_btn.clicked.connect(self._proceed)
|
||||
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
||||
self.skip_btn.clicked.connect(self._proceed)
|
||||
self.end_box = QLabel(wordWrap=True)
|
||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.end_box.setMargin(10)
|
||||
self.qr = MatterQrWidget(SetupPayload(), 220)
|
||||
for w in (self.guide_title, self.step_text, self.qr):
|
||||
cl.addWidget(w)
|
||||
cl.addLayout(self.options)
|
||||
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
||||
cl.addWidget(w)
|
||||
cl.addStretch(1)
|
||||
nav = QHBoxLayout()
|
||||
self.back_btn = QPushButton("← Back")
|
||||
self.back_btn.clicked.connect(self._back)
|
||||
self.done_btn = QPushButton("Back to checks list")
|
||||
self.done_btn.clicked.connect(self._cancel)
|
||||
nav.addWidget(self.back_btn)
|
||||
nav.addStretch(1)
|
||||
nav.addWidget(self.done_btn)
|
||||
cl.addLayout(nav)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.addWidget(self.picker, 1)
|
||||
lay.addWidget(self.card, 1)
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
def _start_selected(self) -> None:
|
||||
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
||||
if it:
|
||||
self._start(it.data(Qt.ItemDataRole.UserRole))
|
||||
|
||||
def _start(self, guide_id: str) -> None:
|
||||
self.runner.start(guide_id)
|
||||
self.refresh()
|
||||
|
||||
def _answer(self, key: str) -> None:
|
||||
self.runner.answer(key)
|
||||
self.refresh()
|
||||
|
||||
def _proceed(self) -> None:
|
||||
self.runner.proceed()
|
||||
self.refresh()
|
||||
|
||||
def _back(self) -> None:
|
||||
self.runner.back()
|
||||
self.refresh()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.runner.cancel()
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------- render
|
||||
def refresh(self) -> None:
|
||||
"""Called after every interaction and periodically (timeouts, countdown)."""
|
||||
self.runner.tick()
|
||||
v = self.runner.view()
|
||||
running = v.guide is not None
|
||||
self.picker.setVisible(not running)
|
||||
self.card.setVisible(running)
|
||||
if not running:
|
||||
self._fill_picker()
|
||||
self._shown_key = None
|
||||
return
|
||||
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
||||
if key != self._shown_key:
|
||||
self._shown_key = key
|
||||
self._render_step(v)
|
||||
if v.waiting:
|
||||
total = v.step.timeout_s
|
||||
self.wait_bar.setMaximum(int(total))
|
||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||
m, s = divmod(int(v.remaining_s), 60)
|
||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
||||
self.note.setText(v.note)
|
||||
self.note.setVisible(bool(v.note))
|
||||
self.back_btn.setEnabled(v.can_back)
|
||||
|
||||
def _fill_picker(self) -> None:
|
||||
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
||||
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
||||
if [w[0] for w in wanted] == current:
|
||||
return
|
||||
self.guide_list.clear()
|
||||
for gid, title, summary in wanted:
|
||||
it = QListWidgetItem(f"{title}\n {summary}")
|
||||
it.setData(Qt.ItemDataRole.UserRole, gid)
|
||||
self.guide_list.addItem(it)
|
||||
self.guide_list.setCurrentRow(0)
|
||||
|
||||
def _render_step(self, v) -> None:
|
||||
self.guide_title.setText(v.guide.title)
|
||||
while self.options.count():
|
||||
w = self.options.takeAt(0).widget()
|
||||
if w:
|
||||
# Detach now; deleteLater alone leaves it visible until the
|
||||
# event loop processes the deferred delete.
|
||||
w.hide()
|
||||
w.setParent(None)
|
||||
w.deleteLater()
|
||||
is_end = v.end is not None
|
||||
self.step_text.setVisible(not is_end)
|
||||
self.step_text.setText(_esc(v.text))
|
||||
show_qr = bool(v.step and v.step.show_qr)
|
||||
if show_qr:
|
||||
self.qr.set_payload(self._matter_payload())
|
||||
self.qr.setVisible(show_qr)
|
||||
for opt_key, label in v.options:
|
||||
b = QPushButton(label)
|
||||
b.setMinimumHeight(34)
|
||||
if v.step and v.step.type == "led":
|
||||
b.setIcon(_swatch(opt_key))
|
||||
b.setIconSize(QSize(22, 22))
|
||||
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
||||
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
||||
self.options.addWidget(b)
|
||||
step_type = v.step.type if v.step else None
|
||||
self.continue_btn.setVisible(step_type == "info")
|
||||
self.skip_btn.setVisible(step_type == "wait")
|
||||
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 ""))
|
||||
+464
-324
@@ -1,324 +1,464 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import __version__
|
||||
from ..core.logfile import read_log_file
|
||||
from ..core.logline import LineKind, LogLine, host_event
|
||||
from ..core.session import SessionLog
|
||||
from ..diag.engine import DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner
|
||||
from ..serialio.manager import SerialManager, State
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
from .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
from .guide_panel import GuidePanel
|
||||
|
||||
MAX_LINES = 20000
|
||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||
COLORS = {
|
||||
"E": "#ff6b6b",
|
||||
"W": "#f5c451",
|
||||
"I": "#d4d4d4",
|
||||
LineKind.BOOT: "#6fc3ff",
|
||||
LineKind.RESET: "#6fc3ff",
|
||||
LineKind.PANIC: "#ff4dd2",
|
||||
LineKind.HOST: "#7ee07e",
|
||||
LineKind.RAW: "#a8a8a8",
|
||||
}
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
|
||||
|
||||
line = Signal(object)
|
||||
state = Signal(object, str)
|
||||
|
||||
|
||||
class SerialTab(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.line.connect(self._on_line)
|
||||
self._bridge.state.connect(self._on_state)
|
||||
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
|
||||
self._session = SessionLog()
|
||||
self._ports: list[PortInfo] = []
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine = DiagnosticsEngine()
|
||||
self._diag_dirty = True
|
||||
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
|
||||
|
||||
# --- controls
|
||||
self.port_combo = QComboBox(minimumWidth=320)
|
||||
self.all_ports = QCheckBox("Show all ports")
|
||||
self.refresh_btn = QPushButton("Refresh")
|
||||
self.connect_btn = QPushButton("Connect")
|
||||
self.reset_btn = QPushButton("Reset && capture")
|
||||
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
|
||||
self.filter_combo = QComboBox()
|
||||
self.filter_combo.addItems(LEVEL_FILTERS)
|
||||
self.clear_btn = QPushButton("Clear view")
|
||||
self.save_btn = QPushButton("Save view as...")
|
||||
self.folder_btn = QPushButton("Open logs folder")
|
||||
self.analyze_btn = QPushButton("Analyze log file...")
|
||||
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
|
||||
self.copy_btn = QPushButton("Copy report")
|
||||
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
||||
self.connect_btn, self.reset_btn):
|
||||
row1.addWidget(w)
|
||||
row1.addStretch(1)
|
||||
row2 = QHBoxLayout()
|
||||
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
|
||||
self.analyze_btn, self.copy_btn):
|
||||
row2.addWidget(w)
|
||||
row2.addStretch(1)
|
||||
|
||||
self.view = QPlainTextEdit(readOnly=True)
|
||||
self.view.setMaximumBlockCount(MAX_LINES)
|
||||
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
|
||||
mono.setStyleHint(QFont.StyleHint.Monospace)
|
||||
mono.setPointSize(10)
|
||||
self.view.setFont(mono)
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
self.diag = DiagnosticsPanel()
|
||||
self.guide = GuidePanel(self._runner, self._engine)
|
||||
self.side_tabs = QTabWidget()
|
||||
self.side_tabs.addTab(self.diag, "Diagnosis")
|
||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.addWidget(self.view)
|
||||
split.addWidget(self.side_tabs)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 2)
|
||||
split.setSizes([700, 480])
|
||||
|
||||
self.status = QLabel()
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(row1)
|
||||
layout.addLayout(row2)
|
||||
layout.addWidget(split, 1)
|
||||
layout.addWidget(self.status)
|
||||
|
||||
# --- wiring
|
||||
self.refresh_btn.clicked.connect(self.refresh_ports)
|
||||
self.all_ports.toggled.connect(self.refresh_ports)
|
||||
self.connect_btn.clicked.connect(self._toggle_connect)
|
||||
self.reset_btn.clicked.connect(self._manager.reset_board)
|
||||
self.clear_btn.clicked.connect(self.view.clear)
|
||||
self.save_btn.clicked.connect(self._save_view)
|
||||
self.folder_btn.clicked.connect(self._open_folder)
|
||||
self.analyze_btn.clicked.connect(self._analyze_file)
|
||||
self.copy_btn.clicked.connect(self._copy_report)
|
||||
|
||||
# Report refresh is throttled; lines can arrive at >100/s.
|
||||
self._diag_timer = QTimer(self, interval=400)
|
||||
self._diag_timer.timeout.connect(self._refresh_diag)
|
||||
self._diag_timer.start()
|
||||
|
||||
# Auto-detect a box being plugged in while idle.
|
||||
self._poll = QTimer(self, interval=2000)
|
||||
self._poll.timeout.connect(self._auto_refresh)
|
||||
self._poll.start()
|
||||
|
||||
self.refresh_ports()
|
||||
self._on_state(State.DISCONNECTED, "")
|
||||
|
||||
# ---- ports -------------------------------------------------------
|
||||
def refresh_ports(self) -> None:
|
||||
current = self.port_combo.currentData()
|
||||
self._ports = list_serial_ports(include_all=self.all_ports.isChecked())
|
||||
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)
|
||||
idx = self.port_combo.findData(current)
|
||||
self.port_combo.setCurrentIndex(max(idx, 0))
|
||||
|
||||
def _auto_refresh(self) -> None:
|
||||
if self._manager.is_running:
|
||||
return
|
||||
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())]
|
||||
if devices != [p.device for p in self._ports]:
|
||||
self.refresh_ports()
|
||||
|
||||
def _selected_port(self) -> PortInfo | None:
|
||||
dev = self.port_combo.currentData()
|
||||
return next((p for p in self._ports if p.device == dev), None)
|
||||
|
||||
# ---- connection --------------------------------------------------
|
||||
def _toggle_connect(self) -> None:
|
||||
if self._manager.is_running:
|
||||
# Session file is closed in _on_state(DISCONNECTED), after the
|
||||
# reader thread's final queued lines have been written.
|
||||
self._manager.disconnect()
|
||||
return
|
||||
port = self._selected_port()
|
||||
if port is None:
|
||||
return
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._diag_dirty = True
|
||||
self._session.start(port.mac or port.device)
|
||||
self._manager.connect(port)
|
||||
|
||||
def _on_state(self, state: State, detail: str) -> None:
|
||||
running = state != State.DISCONNECTED
|
||||
if not running and self._session.path is not None:
|
||||
self._session.write_report(self._engine.report().to_text())
|
||||
self._session.stop()
|
||||
self.connect_btn.setText("Disconnect" if running else "Connect")
|
||||
self.reset_btn.setEnabled(state == State.CONNECTED)
|
||||
self.port_combo.setEnabled(not running)
|
||||
self.all_ports.setEnabled(not running)
|
||||
self.refresh_btn.setEnabled(not running)
|
||||
# Offline analysis would mix with the live session; disconnect first.
|
||||
self.analyze_btn.setEnabled(not running)
|
||||
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
sess = f" | Session log: {self._session.path}" if self._session.path else ""
|
||||
c = self._counts
|
||||
self.status.setText(
|
||||
f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} "
|
||||
f"errors: {c['E']} warnings: {c['W']}{sess}"
|
||||
)
|
||||
|
||||
# ---- lines -------------------------------------------------------
|
||||
def _on_line(self, line: LogLine) -> None:
|
||||
self._session.write(line)
|
||||
self._engine.feed(line)
|
||||
self._runner.feed(line)
|
||||
self._diag_dirty = True
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
if line.level in ("E", "W"):
|
||||
self._counts[line.level] += 1
|
||||
self._update_status()
|
||||
|
||||
allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
|
||||
if allowed is not None and line.kind != LineKind.HOST and not (
|
||||
line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET)
|
||||
):
|
||||
return
|
||||
self._append(line)
|
||||
|
||||
def _append(self, line: LogLine) -> None:
|
||||
sb = self.view.verticalScrollBar()
|
||||
at_bottom = sb.value() >= sb.maximum() - 4
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
|
||||
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST):
|
||||
fmt.setFontWeight(QFont.Weight.Bold)
|
||||
cur = QTextCursor(self.view.document())
|
||||
cur.movePosition(QTextCursor.MoveOperation.End)
|
||||
if not self.view.document().isEmpty():
|
||||
cur.insertBlock()
|
||||
cur.insertText(line.text, fmt)
|
||||
if at_bottom:
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
# ---- diagnostics -------------------------------------------------
|
||||
def _refresh_diag(self) -> None:
|
||||
if self._diag_dirty:
|
||||
self._diag_dirty = False
|
||||
self.diag.show_report(self._engine.report())
|
||||
self.guide.refresh() # countdowns and timeouts need a steady tick
|
||||
|
||||
# ---- guided troubleshooting hooks ------------------------------------
|
||||
def _guide_action(self, action: str) -> bool:
|
||||
if action == "reset" and self._manager.state == State.CONNECTED:
|
||||
self._manager.reset_board()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _guide_log(self, text: str) -> None:
|
||||
# Technician answers go into the session log and the log view.
|
||||
line = host_event(text)
|
||||
self._session.write(line)
|
||||
self._append(line)
|
||||
self._diag_dirty = True
|
||||
|
||||
def _copy_report(self) -> None:
|
||||
QGuiApplication.clipboard().setText(self._engine.report().to_text())
|
||||
|
||||
def _analyze_file(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)")
|
||||
if not path:
|
||||
return
|
||||
lines = read_log_file(Path(path))
|
||||
self.view.clear()
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
for line in lines:
|
||||
self._engine.feed(line)
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
if line.level in ("E", "W"):
|
||||
self._counts[line.level] += 1
|
||||
self._append(line)
|
||||
self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)"
|
||||
self._update_status()
|
||||
self._diag_dirty = True
|
||||
self._refresh_diag()
|
||||
|
||||
# ---- misc --------------------------------------------------------
|
||||
def _save_view(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
|
||||
if path:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(self.view.toPlainText())
|
||||
|
||||
def _open_folder(self) -> None:
|
||||
self._session.root.mkdir(parents=True, exist_ok=True)
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root)))
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._manager.disconnect()
|
||||
self._session.stop()
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}")
|
||||
self.resize(1200, 800)
|
||||
tabs = QTabWidget()
|
||||
guide = QTextBrowser(openExternalLinks=True)
|
||||
guide.setHtml(cheatsheet_html())
|
||||
self.serial_tab = SerialTab()
|
||||
tabs.addTab(guide, "Quick start")
|
||||
tabs.addTab(self.serial_tab, "Serial monitor")
|
||||
self.setCentralWidget(tabs)
|
||||
|
||||
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||
self.serial_tab.shutdown()
|
||||
super().closeEvent(event)
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import __version__
|
||||
from ..core.logfile import read_log_file
|
||||
from ..core.logline import LineKind, LogLine, host_event
|
||||
from ..core.session import SessionLog
|
||||
from ..diag.engine import DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner
|
||||
from ..serialio.commands import loggable
|
||||
from ..serialio.manager import SerialManager, State
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
from ..matter.payload import SetupPayload, payload_from_log
|
||||
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
||||
from .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
from .flash_tab import FlashTab
|
||||
from .matter_qr import MatterQrDialog
|
||||
from .guide_panel import GuidePanel
|
||||
from .sd_tab import SdTab
|
||||
from .wifi_tab import WifiTab
|
||||
|
||||
MAX_LINES = 20000
|
||||
# "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 = {
|
||||
"E": "#ff6b6b",
|
||||
"W": "#f5c451",
|
||||
"I": "#d4d4d4",
|
||||
LineKind.BOOT: "#6fc3ff",
|
||||
LineKind.RESET: "#6fc3ff",
|
||||
LineKind.PANIC: "#ff4dd2",
|
||||
LineKind.HOST: "#7ee07e",
|
||||
LineKind.KOTA: "#5fd7e8",
|
||||
LineKind.RAW: "#a8a8a8",
|
||||
}
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
|
||||
|
||||
line = Signal(object)
|
||||
state = Signal(object, str)
|
||||
|
||||
|
||||
class SerialTab(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.line.connect(self._on_line)
|
||||
self._bridge.state.connect(self._on_state)
|
||||
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
|
||||
self._session = SessionLog()
|
||||
self._ports: list[PortInfo] = []
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine = DiagnosticsEngine()
|
||||
self._diag_dirty = True
|
||||
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
|
||||
self._line_listeners: list = [] # other tabs following the live log (Wi-Fi over USB)
|
||||
|
||||
# --- controls
|
||||
self.port_combo = QComboBox(minimumWidth=320)
|
||||
self.all_ports = QCheckBox("Show all ports")
|
||||
self.refresh_btn = QPushButton("Refresh")
|
||||
self.connect_btn = QPushButton("Connect")
|
||||
self.reset_btn = QPushButton("Reset && capture")
|
||||
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
|
||||
self.filter_combo = QComboBox()
|
||||
self.filter_combo.addItems(LEVEL_FILTERS)
|
||||
self.clear_btn = QPushButton("Clear view")
|
||||
self.save_btn = QPushButton("Save view as...")
|
||||
self.folder_btn = QPushButton("Open logs folder")
|
||||
self.analyze_btn = QPushButton("Analyze log file...")
|
||||
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
|
||||
self.copy_btn = QPushButton("Copy report")
|
||||
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
|
||||
self.service_btn = QPushButton("Service report...")
|
||||
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
|
||||
"filled in; check it and press Save there")
|
||||
self._settings = QSettings("KOTA", "TechnicianTool")
|
||||
self.auto_service = QCheckBox("Open service report on disconnect")
|
||||
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
|
||||
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
|
||||
self._active_port: PortInfo | None = None
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
||||
self.connect_btn, self.reset_btn):
|
||||
row1.addWidget(w)
|
||||
row1.addStretch(1)
|
||||
row2 = QHBoxLayout()
|
||||
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
|
||||
self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service):
|
||||
row2.addWidget(w)
|
||||
row2.addStretch(1)
|
||||
|
||||
self.view = QPlainTextEdit(readOnly=True)
|
||||
self.view.setMaximumBlockCount(MAX_LINES)
|
||||
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
|
||||
mono.setStyleHint(QFont.StyleHint.Monospace)
|
||||
mono.setPointSize(10)
|
||||
self.view.setFont(mono)
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
self.diag = DiagnosticsPanel()
|
||||
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
|
||||
self.side_tabs = QTabWidget()
|
||||
self.side_tabs.addTab(self.diag, "Diagnosis")
|
||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.addWidget(self.view)
|
||||
split.addWidget(self.side_tabs)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 2)
|
||||
split.setSizes([700, 480])
|
||||
|
||||
self.status = QLabel()
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(row1)
|
||||
layout.addLayout(row2)
|
||||
layout.addWidget(split, 1)
|
||||
layout.addWidget(self.status)
|
||||
|
||||
# --- wiring
|
||||
self.refresh_btn.clicked.connect(self.refresh_ports)
|
||||
self.all_ports.toggled.connect(self.refresh_ports)
|
||||
self.connect_btn.clicked.connect(self._toggle_connect)
|
||||
self.reset_btn.clicked.connect(self._manager.reset_board)
|
||||
self.clear_btn.clicked.connect(self.view.clear)
|
||||
self.save_btn.clicked.connect(self._save_view)
|
||||
self.folder_btn.clicked.connect(self._open_folder)
|
||||
self.analyze_btn.clicked.connect(self._analyze_file)
|
||||
self.copy_btn.clicked.connect(self._copy_report)
|
||||
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
|
||||
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
|
||||
|
||||
# Report refresh is throttled; lines can arrive at >100/s.
|
||||
self._diag_timer = QTimer(self, interval=400)
|
||||
self._diag_timer.timeout.connect(self._refresh_diag)
|
||||
self._diag_timer.start()
|
||||
|
||||
# Auto-detect a box being plugged in while idle.
|
||||
self._poll = QTimer(self, interval=2000)
|
||||
self._poll.timeout.connect(self._auto_refresh)
|
||||
self._poll.start()
|
||||
|
||||
self.refresh_ports()
|
||||
self._on_state(State.DISCONNECTED, "")
|
||||
|
||||
# ---- ports -------------------------------------------------------
|
||||
def refresh_ports(self) -> None:
|
||||
current = self.port_combo.currentData()
|
||||
self._ports = list_serial_ports(include_all=self.all_ports.isChecked())
|
||||
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)
|
||||
idx = self.port_combo.findData(current)
|
||||
self.port_combo.setCurrentIndex(max(idx, 0))
|
||||
|
||||
def _auto_refresh(self) -> None:
|
||||
if self._manager.is_running:
|
||||
return
|
||||
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())]
|
||||
if devices != [p.device for p in self._ports]:
|
||||
self.refresh_ports()
|
||||
|
||||
def _selected_port(self) -> PortInfo | None:
|
||||
dev = self.port_combo.currentData()
|
||||
return next((p for p in self._ports if p.device == dev), None)
|
||||
|
||||
# ---- connection --------------------------------------------------
|
||||
def _toggle_connect(self) -> None:
|
||||
if self._manager.is_running:
|
||||
# Session file is closed in _on_state(DISCONNECTED), after the
|
||||
# reader thread's final queued lines have been written.
|
||||
self._manager.disconnect()
|
||||
return
|
||||
port = self._selected_port()
|
||||
if port is None:
|
||||
return
|
||||
self._connect(port)
|
||||
|
||||
def _connect(self, port: PortInfo) -> None:
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._diag_dirty = True
|
||||
self._session.start(port.mac or port.device)
|
||||
self._active_port = port
|
||||
self._manager.connect(port)
|
||||
|
||||
def matter_payload(self) -> SetupPayload:
|
||||
"""Pairing payload 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
|
||||
|
||||
# ---- commands to the box (firmware usb_console) -----------------------
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._manager.state == State.CONNECTED
|
||||
|
||||
@property
|
||||
def active_port(self) -> PortInfo | None:
|
||||
return self._active_port if self._manager.is_running else None
|
||||
|
||||
def add_line_listener(self, callback) -> None:
|
||||
self._line_listeners.append(callback)
|
||||
|
||||
def send_command(self, command: str) -> bool:
|
||||
"""Write one "kota ..." line to the box; False when not connected.
|
||||
The session log gets the command without the Wi-Fi password."""
|
||||
if not self.is_connected:
|
||||
return False
|
||||
self._manager.write((command + "\n").encode("ascii"))
|
||||
if not command.startswith(("kota health", "kota ping")): # polled; would flood the log
|
||||
self._guide_log(f"sent to the box: {loggable(command)}")
|
||||
return True
|
||||
|
||||
def connect_first_box(self) -> bool:
|
||||
"""Connect to the first sensorbox port found (for tabs that need the box)."""
|
||||
if self._manager.is_running:
|
||||
return True
|
||||
self.refresh_ports()
|
||||
port = next((p for p in self._ports if p.is_sensorbox), None)
|
||||
if port is None:
|
||||
return False
|
||||
self.connect_port(port, capture=False)
|
||||
return True
|
||||
|
||||
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:
|
||||
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:
|
||||
self._session.write_report(self._engine.report().to_text())
|
||||
if (self.auto_service.isChecked() and self._engine.lines
|
||||
and not getattr(self, "_no_auto_service", False)):
|
||||
self._open_service_report(auto=True)
|
||||
self._session.stop()
|
||||
self.connect_btn.setText("Disconnect" if running else "Connect")
|
||||
self.reset_btn.setEnabled(state == State.CONNECTED)
|
||||
self.port_combo.setEnabled(not running)
|
||||
self.all_ports.setEnabled(not running)
|
||||
self.refresh_btn.setEnabled(not running)
|
||||
# Offline analysis would mix with the live session; disconnect first.
|
||||
self.analyze_btn.setEnabled(not running)
|
||||
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
sess = f" | Session log: {self._session.path}" if self._session.path else ""
|
||||
c = self._counts
|
||||
self.status.setText(
|
||||
f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} "
|
||||
f"errors: {c['E']} warnings: {c['W']}{sess}"
|
||||
)
|
||||
|
||||
# ---- lines -------------------------------------------------------
|
||||
def _on_line(self, line: LogLine) -> None:
|
||||
self._session.write(line)
|
||||
self._engine.feed(line)
|
||||
self._runner.feed(line)
|
||||
for listener in self._line_listeners:
|
||||
listener(line)
|
||||
self._diag_dirty = True
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
if line.level in ("E", "W"):
|
||||
self._counts[line.level] += 1
|
||||
self._update_status()
|
||||
|
||||
if self._visible(line):
|
||||
self._append(line)
|
||||
|
||||
def _visible(self, line: LogLine) -> bool:
|
||||
allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
|
||||
if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET):
|
||||
return True
|
||||
if allowed == "KOTA":
|
||||
return line.kind == LineKind.KOTA or line.level == "E"
|
||||
return line.level in allowed
|
||||
|
||||
def _append(self, line: LogLine) -> None:
|
||||
sb = self.view.verticalScrollBar()
|
||||
at_bottom = sb.value() >= sb.maximum() - 4
|
||||
fmt = QTextCharFormat()
|
||||
colour = COLORS.get(line.kind) if line.kind == LineKind.KOTA and line.level == "I" else None
|
||||
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)
|
||||
cur = QTextCursor(self.view.document())
|
||||
cur.movePosition(QTextCursor.MoveOperation.End)
|
||||
if not self.view.document().isEmpty():
|
||||
cur.insertBlock()
|
||||
cur.insertText(line.text, fmt)
|
||||
if at_bottom:
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
# ---- diagnostics -------------------------------------------------
|
||||
def _refresh_diag(self) -> None:
|
||||
if self._diag_dirty:
|
||||
self._diag_dirty = False
|
||||
self.diag.show_report(self._engine.report())
|
||||
self.guide.refresh() # countdowns and timeouts need a steady tick
|
||||
|
||||
# ---- guided troubleshooting hooks ------------------------------------
|
||||
def _guide_action(self, action: str) -> bool:
|
||||
if action == "reset" and self._manager.state == State.CONNECTED:
|
||||
self._manager.reset_board()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _guide_log(self, text: str) -> None:
|
||||
# Technician answers go into the session log and the log view.
|
||||
line = host_event(text)
|
||||
self._session.write(line)
|
||||
self._append(line)
|
||||
self._diag_dirty = True
|
||||
|
||||
def _copy_report(self) -> None:
|
||||
QGuiApplication.clipboard().setText(self._engine.report().to_text())
|
||||
|
||||
def _analyze_file(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)")
|
||||
if not path:
|
||||
return
|
||||
lines = read_log_file(Path(path))
|
||||
self.view.clear()
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
for line in lines:
|
||||
self._engine.feed(line)
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
if line.level in ("E", "W"):
|
||||
self._counts[line.level] += 1
|
||||
self._append(line)
|
||||
self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)"
|
||||
self._update_status()
|
||||
self._diag_dirty = True
|
||||
self._refresh_diag()
|
||||
|
||||
# ---- misc --------------------------------------------------------
|
||||
def _save_view(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
|
||||
if path:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(self.view.toPlainText())
|
||||
|
||||
def _open_folder(self) -> None:
|
||||
self._session.root.mkdir(parents=True, exist_ok=True)
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root)))
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._manager.disconnect()
|
||||
self._session.stop()
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}")
|
||||
self.resize(1200, 800)
|
||||
tabs = QTabWidget()
|
||||
guide = QTextBrowser(openExternalLinks=True)
|
||||
guide.setHtml(cheatsheet_html())
|
||||
self.serial_tab = SerialTab()
|
||||
self.flash_tab = FlashTab(self.serial_tab)
|
||||
tabs.addTab(guide, "Quick start")
|
||||
tabs.addTab(self.serial_tab, "Serial monitor")
|
||||
tabs.addTab(self.flash_tab, "Flash firmware")
|
||||
self.wifi_tab = WifiTab(self.serial_tab)
|
||||
tabs.addTab(self.wifi_tab, "Wi-Fi (USB)")
|
||||
self.sd_tab = SdTab(self.serial_tab)
|
||||
tabs.addTab(self.sd_tab, "SD card")
|
||||
self.setCentralWidget(tabs)
|
||||
settings_menu = self.menuBar().addMenu("Settings")
|
||||
settings_menu.addAction("Service system address...", self._edit_service_base)
|
||||
|
||||
def _edit_service_base(self) -> None:
|
||||
st = self.serial_tab
|
||||
text, ok = QInputDialog.getText(
|
||||
self, "Service system address",
|
||||
"Address of the KOTA service system (the box MAC is added at the end):",
|
||||
text=st._service_base())
|
||||
if ok and text.strip().startswith(("https://", "http://")):
|
||||
st._settings.setValue("service/base", text.strip())
|
||||
elif ok:
|
||||
QMessageBox.warning(self, "Service system address", "The address must start with https://")
|
||||
|
||||
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||
self.serial_tab.shutdown()
|
||||
super().closeEvent(event)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Wi-Fi over USB: read / write / remove the box's Wi-Fi without opening the case.
|
||||
|
||||
Needs firmware with the USB console (usb_console, api=1). The job itself is
|
||||
serialio.commands.WifiFlow; this tab only collects the input and shows the
|
||||
steps. Lines arrive from the Serial monitor's connection (one port owner).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
|
||||
from ..core.logline import LogLine
|
||||
from ..serialio.commands import FlowEvent, WifiFlow, validate_wifi
|
||||
|
||||
INTRO = (
|
||||
"Stores the Wi-Fi name and password on the box's SD card over the USB cable - <b>no need to open the "
|
||||
"case</b>. The box then restarts and uses this network instead of Matter. Needs firmware with USB commands "
|
||||
"(<i>Flash firmware</i> tab, newest file). Only 2.4 GHz networks with a password (8-63 characters)."
|
||||
)
|
||||
MARK = {True: ("✔", "#1b7a33"), False: ("✖", "#b03a2e"), None: ("…", "#2e6da4")}
|
||||
|
||||
|
||||
class WifiTab(QWidget):
|
||||
def __init__(self, serial_tab, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.serial_tab = serial_tab
|
||||
self._flow: WifiFlow | None = None
|
||||
|
||||
intro = QLabel(INTRO, wordWrap=True)
|
||||
|
||||
# 1. box
|
||||
box = QGroupBox("1. Sensorbox")
|
||||
self.box_state = QLabel(wordWrap=True)
|
||||
self.connect_btn = QPushButton("Connect to the box")
|
||||
self.connect_btn.clicked.connect(self._connect)
|
||||
self.read_btn = QPushButton("Read current Wi-Fi settings")
|
||||
self.read_btn.clicked.connect(lambda: self._run("read"))
|
||||
bl = QVBoxLayout(box)
|
||||
bl.addWidget(self.box_state)
|
||||
brow = QHBoxLayout()
|
||||
brow.addWidget(self.connect_btn)
|
||||
brow.addWidget(self.read_btn)
|
||||
brow.addStretch(1)
|
||||
bl.addLayout(brow)
|
||||
|
||||
# 2. network
|
||||
net = QGroupBox("2. Wi-Fi network for this box")
|
||||
self.ssid = QLineEdit(placeholderText="network name (SSID), exactly as on the router")
|
||||
self.password = QLineEdit(placeholderText="8-63 characters")
|
||||
self.password.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
show = QCheckBox("Show password")
|
||||
show.toggled.connect(lambda on: self.password.setEchoMode(
|
||||
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
|
||||
form = QFormLayout()
|
||||
form.addRow("Network (SSID)", self.ssid)
|
||||
prow = QHBoxLayout()
|
||||
prow.addWidget(self.password, 1)
|
||||
prow.addWidget(show)
|
||||
form.addRow("Password", prow)
|
||||
self.input_problem = QLabel(wordWrap=True)
|
||||
self.input_problem.setStyleSheet("color:#b03a2e")
|
||||
self.write_btn = QPushButton("Write to the box and connect")
|
||||
self.write_btn.setMinimumHeight(34)
|
||||
self.write_btn.clicked.connect(lambda: self._run("set"))
|
||||
self.clear_btn = QPushButton("Remove Wi-Fi (back to Matter)")
|
||||
self.clear_btn.clicked.connect(self._clear)
|
||||
nl = QVBoxLayout(net)
|
||||
nl.addLayout(form)
|
||||
nl.addWidget(self.input_problem)
|
||||
arow = QHBoxLayout()
|
||||
arow.addWidget(self.write_btn)
|
||||
arow.addWidget(self.clear_btn)
|
||||
arow.addStretch(1)
|
||||
nl.addLayout(arow)
|
||||
|
||||
# 3. progress
|
||||
prog = QGroupBox("3. Progress")
|
||||
self.steps = QListWidget()
|
||||
self.steps.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||
self.result = QLabel(wordWrap=True)
|
||||
self.result.setMargin(8)
|
||||
self.result.setVisible(False)
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self._cancel)
|
||||
pl = QVBoxLayout(prog)
|
||||
pl.addWidget(self.steps)
|
||||
pl.addWidget(self.result)
|
||||
pl.addWidget(self.cancel_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addWidget(intro)
|
||||
lay.addWidget(box)
|
||||
lay.addWidget(net)
|
||||
lay.addWidget(prog, 1)
|
||||
|
||||
serial_tab.add_line_listener(self._on_line)
|
||||
for w in (self.ssid, self.password):
|
||||
w.textChanged.connect(self._update)
|
||||
self._timer = QTimer(self, interval=500)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
self._timer.start()
|
||||
self._update()
|
||||
|
||||
# ------------------------------------------------------------ state
|
||||
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
|
||||
self._update()
|
||||
super().showEvent(event)
|
||||
|
||||
def _update(self) -> None:
|
||||
connected = self.serial_tab.is_connected
|
||||
busy = self._flow is not None and not self._flow.finished
|
||||
port = self.serial_tab.active_port
|
||||
eng = self.serial_tab.engine
|
||||
if connected and port:
|
||||
fw = eng.firmware.describe() if eng.firmware.elf_sha else "firmware not identified yet"
|
||||
api = {None: "", 1: " - USB commands supported"}.get(eng.console_api, f" - USB commands api {eng.console_api}")
|
||||
self.box_state.setText(f"Connected: <b>{port.label}</b><br><small>{fw}{api}</small>")
|
||||
elif self.serial_tab.active_port:
|
||||
self.box_state.setText("Waiting for the box... (it may be restarting)")
|
||||
else:
|
||||
self.box_state.setText("Not connected. Plug in the sensorbox (native USB) and press "
|
||||
"<b>Connect to the box</b>.")
|
||||
problems = validate_wifi(self.ssid.text(), self.password.text())
|
||||
typed = bool(self.ssid.text() or self.password.text())
|
||||
self.input_problem.setText(" ".join(problems) if typed else "")
|
||||
self.connect_btn.setVisible(not self.serial_tab.active_port)
|
||||
for w in (self.read_btn, self.clear_btn):
|
||||
w.setEnabled(connected and not busy)
|
||||
self.write_btn.setEnabled(connected and not busy and not problems)
|
||||
self.ssid.setEnabled(not busy)
|
||||
self.password.setEnabled(not busy)
|
||||
self.cancel_btn.setVisible(busy)
|
||||
|
||||
def _tick(self) -> None:
|
||||
if self._flow is not None:
|
||||
self._flow.tick()
|
||||
self._update()
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
def _connect(self) -> None:
|
||||
if not self.serial_tab.connect_first_box():
|
||||
QMessageBox.information(self, "Wi-Fi over USB", "No sensorbox found. Check the USB cable (data-capable) "
|
||||
"and that the box is powered, then try again.")
|
||||
self._update()
|
||||
|
||||
def _clear(self) -> None:
|
||||
if QMessageBox.question(
|
||||
self, "Remove Wi-Fi",
|
||||
"Remove the Wi-Fi network from the box?\n\nThe box restarts and uses Matter again. If it was never "
|
||||
"paired in Matter, it has no network until you pair it in the Matter app.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes:
|
||||
self._run("clear")
|
||||
|
||||
def _run(self, action: str) -> None:
|
||||
if action == "set" and QMessageBox.question(
|
||||
self, "Write Wi-Fi",
|
||||
f"Store network '{self.ssid.text()}' on the box?\n\nThe box restarts and connects to this Wi-Fi "
|
||||
"instead of Matter (about 30 s).",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.steps.clear()
|
||||
self.result.setVisible(False)
|
||||
self._flow = WifiFlow(action, self.serial_tab.send_command, self._on_event,
|
||||
ssid=self.ssid.text(), password=self.password.text())
|
||||
self._flow.start()
|
||||
self._update()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
if self._flow is not None:
|
||||
self._flow.cancel()
|
||||
self._update()
|
||||
|
||||
# ------------------------------------------------------------ events
|
||||
def _on_line(self, line: LogLine) -> None:
|
||||
if self._flow is not None:
|
||||
self._flow.feed(line)
|
||||
|
||||
def _on_event(self, ev: FlowEvent) -> None:
|
||||
mark, colour = MARK[ev.ok]
|
||||
item = QListWidgetItem(f"{mark} {ev.text}")
|
||||
item.setForeground(QBrush(QColor(colour)))
|
||||
self.steps.addItem(item)
|
||||
self.steps.scrollToBottom()
|
||||
if self._flow is not None and self._flow.finished or ev.ok is False:
|
||||
ok = ev.ok is True
|
||||
border, bg = ("#2e9e44", "#e3f4e7") if ok else ("#d9534f", "#f9e0df")
|
||||
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
||||
self.result.setText(("<b>Done.</b> " if ok else "<b>Not done.</b> ") + ev.text)
|
||||
self.result.setVisible(True)
|
||||
if ok and self._flow and self._flow.info and self._flow.info.ssid and not self.ssid.text():
|
||||
self.ssid.setText(self._flow.info.ssid) # read: prefill the stored network name
|
||||
self._update()
|
||||
+4
-2
@@ -1,15 +1,17 @@
|
||||
[project]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.3.0"
|
||||
version = "0.9.0"
|
||||
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
||||
requires-python = ">=3.11,<3.14"
|
||||
dependencies = [
|
||||
"pyserial>=3.5",
|
||||
"PySide6-Essentials>=6.7",
|
||||
"esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands
|
||||
"segno>=1.6", # QR code for Matter pairing (pure Python)
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
dev = ["pytest>=8", "pyfatfs>=1.0", "setuptools<81"] # pyfatfs (independent FAT32 check) needs pkg_resources
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
+4
-2
@@ -2,8 +2,10 @@
|
||||
# Double-click in Finder, or run: bash run_macos.command
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
# Keep the virtual environment out of the (Google Drive) tool folder.
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.3"
|
||||
# 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)
|
||||
# One shared Python environment for every version (update_macos.command keeps it current).
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
rem Keep the virtual environment out of the (Google Drive) tool folder.
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.3"
|
||||
rem One shared Python environment for every version (update_windows.bat keeps it current).
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
|
||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||
|
||||
where uv >nul 2>nul
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
--- connected to COM14 ---
|
||||
W (9426) wifi:Haven't to connect to a suitable AP now!
|
||||
--- reset issued by tool ---
|
||||
ESP-ROM:esp32s3-20210327
|
||||
Build:Mar 27 2021
|
||||
rst:0x15 (USB_UART_CHIP_RESET),boot:0xb (SPI_FAST_FLASH_BOOT)
|
||||
Saved PC:0x4037f366
|
||||
SPIWP:0xee
|
||||
mode:DIO, clock div:1
|
||||
load:0x3fce2820,len:0x14f0
|
||||
load:0x403c8700,len:0xd24
|
||||
load:0x403cb700,len:0x2f48
|
||||
entry 0x403c8924
|
||||
I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader
|
||||
I (24) boot: compile time Sep 10 2026 13:24:39
|
||||
I (25) boot: Multicore bootloader
|
||||
I (25) boot: chip revision: v0.2
|
||||
I (28) boot: efuse block revision: v1.4
|
||||
I (32) boot.esp32s3: Boot SPI Speed : 80MHz
|
||||
I (35) boot.esp32s3: SPI Mode : DIO
|
||||
I (39) boot.esp32s3: SPI Flash Size : 8MB
|
||||
I (43) boot: Enabling RNG early entropy source...
|
||||
I (48) boot: Partition Table:
|
||||
I (50) boot: ## Label Usage Type ST Offset Length
|
||||
I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000
|
||||
I (63) boot: 1 nvs WiFi data 01 02 00010000 00020000
|
||||
I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000
|
||||
I (76) boot: 3 otadata OTA data 01 00 00031000 00002000
|
||||
I (82) boot: 4 phy_init RF data 01 01 00033000 00001000
|
||||
I (89) boot: 5 ota_0 OTA app 00 10 00040000 00300000
|
||||
I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000
|
||||
I (102) boot: 7 fctry WiFi data 01 02 00640000 00006000
|
||||
I (109) boot: End of partition table
|
||||
I (112) esp_image: segment 0: paddr=00040020 vaddr=3c190020 size=67420h (422944) map
|
||||
I (194) esp_image: segment 1: paddr=000a7448 vaddr=3fc9c700 size=05d00h ( 23808) load
|
||||
I (200) esp_image: segment 2: paddr=000ad150 vaddr=40374000 size=02ec8h ( 11976) load
|
||||
I (203) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=187370h (1602416) map
|
||||
I (488) esp_image: segment 4: paddr=00237398 vaddr=40376ec8 size=1580ch ( 88076) load
|
||||
I (507) esp_image: segment 5: paddr=0024cbac vaddr=5000002c size=00020h ( 32) load
|
||||
I (517) boot: Loaded app from partition at offset 0x40000
|
||||
I (518) boot: Disabling RNG early entropy source...
|
||||
I (528) octal_psram: vendor id : 0x0d (AP)
|
||||
I (528) octal_psram: dev id : 0x02 (generation 3)
|
||||
I (529) octal_psram: density : 0x03 (64 Mbit)
|
||||
I (530) octal_psram: good-die : 0x01 (Pass)
|
||||
I (535) octal_psram: Latency : 0x01 (Fixed)
|
||||
I (539) octal_psram: VCC : 0x01 (3V)
|
||||
I (543) octal_psram: SRF : 0x01 (Fast Refresh)
|
||||
I (548) octal_psram: BurstType : 0x01 (Hybrid Wrap)
|
||||
I (553) octal_psram: BurstLen : 0x01 (32 Byte)
|
||||
I (557) octal_psram: Readlatency : 0x02 (10 cycles@Fixed)
|
||||
I (562) octal_psram: DriveStrength: 0x00 (1/1)
|
||||
I (567) esp_psram: Found 8MB PSRAM device
|
||||
I (570) esp_psram: Speed: 40MHz
|
||||
I (573) cpu_start: Multicore app
|
||||
I (1307) esp_psram: SPI SRAM memory test OK
|
||||
I (1315) cpu_start: GPIO 44 and 43 are used as console UART I/O pins
|
||||
I (1316) cpu_start: Pro cpu start user code
|
||||
I (1316) cpu_start: cpu freq: 160000000 Hz
|
||||
I (1318) app_init: Application information:
|
||||
I (1322) app_init: Project name: kota-sensorboard
|
||||
I (1327) app_init: App version: 1.0.16
|
||||
I (1331) app_init: Compile time: Sep 28 2026 15:07:57
|
||||
I (1336) app_init: ELF file SHA256: 3ae273ec1...
|
||||
I (1340) app_init: ESP-IDF: v5.5.3-dirty
|
||||
I (1345) efuse_init: Min chip rev: v0.0
|
||||
I (1348) efuse_init: Max chip rev: v0.99
|
||||
I (1353) efuse_init: Chip rev: v0.2
|
||||
I (1357) heap_init: Initializing. RAM available for dynamic allocation:
|
||||
I (1363) heap_init: At 3FCB4AD0 len 00034C40 (211 KiB): RAM
|
||||
I (1368) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM
|
||||
I (1373) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM
|
||||
I (1379) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM
|
||||
I (1384) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator
|
||||
I (1392) spi_flash: detected chip: generic
|
||||
I (1394) spi_flash: flash io: dio
|
||||
I (1400) sleep_gpio: Configure to isolate all GPIO pins in sleep state
|
||||
I (1404) sleep_gpio: Enable automatic switching of GPIO sleep configuration
|
||||
I (1419) main_task: Started on CPU0
|
||||
I (1422) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations
|
||||
I (1430) main_task: Calling app_main()
|
||||
E (1433) ID: [E-MAIN-04] Device MAC address: 14:C1:9F:57:72:48
|
||||
I (1475) KOTA: led color=green pattern=solid
|
||||
I (1676) I2C: Creating I2C mutex
|
||||
I (1694) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688
|
||||
I (1696) OTA_CLIENT: OTA token loaded from NVS: <REDACTED>
|
||||
I (1698) OTA_CLIENT: Device is not in selfcheck mode
|
||||
I (1702) OTA_CLIENT: Update metadata loaded from NVS: version=15, status=2
|
||||
I (1707) OTA_CLIENT: Device is in factory state
|
||||
I (1711) SPI: Creating SPI mutex
|
||||
I (1755) SD_CARD: SD card mounted successfully
|
||||
I (1756) SD_CARD: /sdcard exists, SD card initialized successfully
|
||||
I (1758) INFLUX_BUFFER: Buffer file does not exist, returning count of 0
|
||||
I (1763) KOTA: sd type=SDHC size=29.2GB fs=FAT32 free=29.1GB env_keys=INFLUX_TOKEN,INFLUX_URL buffer_lines=0
|
||||
I (1774) WIFI_CREDENTIALS: SSID/PASSWORD not set in .env; Matter will be used for WiFi provisioning
|
||||
I (1785) MAIN: Matter node created successfully
|
||||
W (1787) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1793) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1802) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1811) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1819) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1828) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1837) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1846) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1854) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1863) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1872) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1881) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1890) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1898) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1907) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1916) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1925) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1933) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
I (1944) pp: pp rom version: e7ae62f
|
||||
I (1945) net80211: net80211 rom version: e7ae62f
|
||||
I (1951) wifi:wifi driver task: 3fcc9610, prio:23, stack:6656, core=0
|
||||
I (1969) wifi:wifi firmware version: 4df78f2
|
||||
I (1970) wifi:wifi certification version: v7.0
|
||||
I (1971) wifi:config NVS flash: enabled
|
||||
I (1971) wifi:config nano formatting: disabled
|
||||
I (1975) wifi:Init data frame dynamic rx buffer num: 32
|
||||
I (1980) wifi:Init static rx mgmt buffer num: 5
|
||||
I (1984) wifi:Init management short buffer num: 32
|
||||
I (1988) wifi:Init dynamic tx buffer num: 32
|
||||
I (1992) wifi:Init static tx FG buffer num: 2
|
||||
I (1997) wifi:Init static rx buffer size: 1600
|
||||
I (2001) wifi:Init static rx buffer num: 10
|
||||
I (2004) wifi:Init dynamic rx buffer num: 32
|
||||
I (2009) wifi_init: rx ba win: 6
|
||||
I (2011) wifi_init: accept mbox: 6
|
||||
I (2014) wifi_init: tcpip mbox: 32
|
||||
I (2018) wifi_init: udp mbox: 6
|
||||
I (2020) wifi_init: tcp mbox: 6
|
||||
I (2023) wifi_init: tcp tx win: 5760
|
||||
I (2027) wifi_init: tcp rx win: 5760
|
||||
I (2030) wifi_init: tcp mss: 1440
|
||||
I (2045) chip[DL]: NVS set: chip-counters/reboot-count = 13 (0xD)
|
||||
I (2048) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC)
|
||||
I (2050) BLE_INIT: BT controller compile version [1bb2f50]
|
||||
I (2053) BLE_INIT: Using main XTAL as clock source
|
||||
I (2058) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1
|
||||
I (2067) BLE_INIT: Bluetooth MAC: 14:c1:9f:57:72:4a
|
||||
I (2072) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10
|
||||
I (2119) NimBLE: GAP procedure initiated: stop advertising.
|
||||
|
||||
I (2122) CHIP[DL]: BLE host-controller synced
|
||||
I (2623) chip[DL]: Starting ESP WiFi layer
|
||||
I (2626) wifi:mode : sta (14:c1:9f:57:72:48)
|
||||
I (2627) wifi:enable tsf
|
||||
W (2628) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2629) chip[DL]:
|
||||
I (2629) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43Attempting to connect WiFi station interface
|
||||
|
||||
I (2641) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
I (2642) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2
|
||||
I (2647) chip[DL]:
|
||||
Done driving station state, nothing else to do...
|
||||
I (2659) chip[SVR]: Initializing subscription resumption storage...
|
||||
I (2666) chip[SVR]: Server initializing...
|
||||
I (2669) chip[TS]: Last Known Good Time: 2026-09-29T14:59:23
|
||||
I (2674) chip[DMG]: AccessControl: initializing
|
||||
I (2678) chip[DMG]: Examples::AccessControlDelegate::Init
|
||||
I (2683) chip[DMG]: AccessControl: setting
|
||||
I (2687) chip[DMG]: DefaultAclStorage: initializing
|
||||
I (2691) chip[DMG]: DefaultAclStorage: 0 entries loaded
|
||||
I (2696) chip[SVR]: WARNING: mTestEventTriggerDelegate is null
|
||||
I (2715) chip[ZCL]: Using ZAP configuration...
|
||||
I (2716) esp_matter_cluster: Cluster plugin init common callback
|
||||
I (2718) chip[DMG]: AccessControlCluster: initializing
|
||||
I (2720) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported
|
||||
I (2730) chip[DIS]: Updating services using commissioning mode 1
|
||||
I (2733) chip[DIS]: CHIP minimal mDNS started advertising.
|
||||
I (2739) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
|
||||
I (2749) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: 995B80B34BC8AAD9.
|
||||
I (2758) chip[DIS]: mDNS service published: _matterc._udp
|
||||
I (2763) chip[IN]: CASE Server enabling CASE session setups
|
||||
I (2769) chip[SVR]: Joining Multicast groups
|
||||
I (2772) chip[SVR]: Server Listening...
|
||||
I (2778) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 **********
|
||||
I (2788) data_model: Dynamic endpoint 0 added
|
||||
E (2790) data_model: Cluster cannot be NULL.
|
||||
I (2795) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 **********
|
||||
I (2806) data_model: Dynamic endpoint 1 added
|
||||
E (2809) data_model: Cluster cannot be NULL.
|
||||
I (2815) chip[DL]:
|
||||
I (2815) MAIN: ESP-Matter started successfully
|
||||
WIFI_EVENT_STA_START
|
||||
W (2821) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2826) chip[DL]: Attempting to connect WiFi station interface
|
||||
E (2832) wifi:sta is connecting, return error
|
||||
E (2836) chip[DL]: esp_wifi_connect() failed: ESP_ERR_WIFI_CONN
|
||||
I (2842) KOTA: boot fw=1.0.16 build=3ae273ec hw=2 mac=14C19F577248 reset=USB_UART_CHIP_RESET mode=matter commissioned=0 last_restart=none
|
||||
I (2843) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable)
|
||||
I (2861) NimBLE: GAP procedure initiated: advertise;
|
||||
I (2865) NimBLE: disc_mode=2
|
||||
I (2867) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40
|
||||
I (2877) NimBLE:
|
||||
|
||||
I (2880) chip[DL]: CHIPoBLE advertising started
|
||||
I (2883) MAIN: Commissioning window opened
|
||||
I (2888) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332
|
||||
I (2896) KOTA: led color=cyan pattern=breathe period=3000ms
|
||||
I (2901) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332
|
||||
W (2909) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2908) KOTA: health up=1s status=OK wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=0 heap=117491 psram=7.9M last_upload=never ota=registered
|
||||
W (3401) wifi:Haven't to connect to a suitable AP now!
|
||||
I (3910) KOTA: rtc running=0 time=2000-01-01T00:00:00
|
||||
W (4402) wifi:Haven't to connect to a suitable AP now!
|
||||
W (5403) wifi:Haven't to connect to a suitable AP now!
|
||||
W (6404) wifi:Haven't to connect to a suitable AP now!
|
||||
I (7219) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (7222) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (7223) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (7226) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (7233) wifi:Haven't to connect to a suitable AP now!
|
||||
E (7238) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (7248) wifi:Haven't to connect to a suitable AP now!
|
||||
I (7252) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (7259) wifi:Haven't to connect to a suitable AP now!
|
||||
E (7264) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (7273) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (7278) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (7379) wifi:Haven't to connect to a suitable AP now!
|
||||
I (7380) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (7383) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (7386) wifi:Haven't to connect to a suitable AP now!
|
||||
E (7391) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (7400) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (7405) wifi:Haven't to connect to a suitable AP now!
|
||||
W (8411) wifi:Haven't to connect to a suitable AP now!
|
||||
W (9415) wifi:Haven't to connect to a suitable AP now!
|
||||
W (10417) wifi:Haven't to connect to a suitable AP now!
|
||||
W (11419) wifi:Haven't to connect to a suitable AP now!
|
||||
I (12201) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (12204) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (12205) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (12208) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (12215) wifi:Haven't to connect to a suitable AP now!
|
||||
E (12220) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (12230) wifi:Haven't to connect to a suitable AP now!
|
||||
I (12234) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (12242) wifi:Haven't to connect to a suitable AP now!
|
||||
E (12247) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (12256) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (12261) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (12361) wifi:Haven't to connect to a suitable AP now!
|
||||
I (12362) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (12366) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (12368) wifi:Haven't to connect to a suitable AP now!
|
||||
E (12373) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (12383) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (12427) wifi:Haven't to connect to a suitable AP now!
|
||||
W (13428) wifi:Haven't to connect to a suitable AP now!
|
||||
I (13912) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
|
||||
I (13917) INFLUX_BUFFER: Buffer file does not exist, returning count of 0
|
||||
W (13920) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
|
||||
I (13943) BME688: Delaying for 10000 microseconds
|
||||
W (14430) wifi:Haven't to connect to a suitable AP now!
|
||||
I (14563) KOTA: mic enabled=1 (GPIO3=0, expected 0)
|
||||
I (14566) Flicker: Flicker sensor initialized on pin 14
|
||||
I (14567) temperature_sensor: Range [20°C ~ 100°C], error < 2°C
|
||||
I (14569) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data
|
||||
W (14573) wifi:
|
||||
I (14569) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data
|
||||
Haven't to connect to a suitable AP now!
|
||||
I (14570) SEN66: Creating sensor mutex
|
||||
I (14572) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms
|
||||
I (14600) Flicker: Starting to collect samples in gather task
|
||||
I (14585) DeviceStatus: Active tasks: 27
|
||||
I (14588) SEN66: Waking up sensor by setting power GPIO to enabled level...
|
||||
I (14570) INFLUX_BUFFER: Buffer file does not exist, returning empty buffer
|
||||
I (14622) INFLUX_BUFFER: Buffer is empty, nothing to flush
|
||||
I (14613) DeviceStatus: Task list:
|
||||
gather_sensor_t R 1 1920 25
|
||||
gather_sensor_t X 1 1368 23
|
||||
IDLE1 R 0 700 6
|
||||
IDLE0 R 0 692 5
|
||||
gather_sensor_t B 1 2556 22
|
||||
reset_task B 0 3356 10
|
||||
ds1307_update B 1 776 18
|
||||
tiT B 18 2420 12
|
||||
gather_task B 1 1916 20
|
||||
gather_task B 1 14288 21
|
||||
ota_task B 1 7516 19
|
||||
CHIP B 1 3700 14
|
||||
health B 1 728 17
|
||||
Tmr Svc B 1 1364 7
|
||||
led_task B 5 868 8
|
||||
nimble_host B 21 1348 16
|
||||
ipc0 S 24 372 1
|
||||
ipc1 S 24 524 2
|
||||
sys_evt B 20 2676 11
|
||||
gather_sensor_t B 1 3180 26
|
||||
esp_timer S 22 3044 3
|
||||
gather_sensor_t B 1 2324 24
|
||||
main B 1 892 4
|
||||
gather_sensor_t S 1 1480 27
|
||||
wifi B 23 3796 13
|
||||
btController B 23 1864 15
|
||||
flicker_gather_ S 24 1988 28
|
||||
usb_console B 1 2840 9
|
||||
|
||||
W (14702) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (15433) wifi:Haven't to connect to a suitable AP now!
|
||||
I (15630) Flicker: Finished collecting samples, notifying main task
|
||||
I (15632) Flicker: Collected 1024 samples for dominant frequency analysis
|
||||
I (15639) SENSOR_GATHERER: Sensor should now be asleep
|
||||
W (15650) AS7341: Reading spectral data from AS7341
|
||||
I (15659) BME688: Delaying for 199517 microseconds
|
||||
W (15710) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (15958) AS7341: Reading basic counts data from AS7341
|
||||
W (15974) AS7341: Reading flicker detection state from AS7341
|
||||
W (16194) as7341: FD Status Register: 0x20 (0b00100000)
|
||||
I (16201) AS7341: Updating Matter attributes with AS7341 data: 10474
|
||||
I (16202) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 **********
|
||||
I (16208) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 10474 **********
|
||||
E (16219) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
|
||||
E (16229) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
|
||||
W (16238) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
I (16241) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null **********
|
||||
I (16256) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1021 **********
|
||||
E (16267) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
|
||||
E (16276) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
|
||||
W (16286) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (16434) wifi:Haven't to connect to a suitable AP now!
|
||||
W (16711) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (17183) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (17186) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (17187) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (17190) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (17198) wifi:Haven't to connect to a suitable AP now!
|
||||
E (17203) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (17212) wifi:Haven't to connect to a suitable AP now!
|
||||
I (17217) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (17224) wifi:Haven't to connect to a suitable AP now!
|
||||
E (17229) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (17239) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (17243) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (17244) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (17291) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (17344) wifi:Haven't to connect to a suitable AP now!
|
||||
I (17346) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (17349) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (17351) wifi:Haven't to connect to a suitable AP now!
|
||||
E (17356) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (17366) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (17437) wifi:Haven't to connect to a suitable AP now!
|
||||
W (17712) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (18256) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (18292) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (18439) wifi:Haven't to connect to a suitable AP now!
|
||||
W (18713) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (19257) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (19293) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (19440) wifi:Haven't to connect to a suitable AP now!
|
||||
I (19615) SEN66: Sent start measurement command, waiting for sensor to be ready...
|
||||
W (19714) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (19928) KOTA: cmd ping ok=1 fw=1.0.16 api=1
|
||||
I (19933) KOTA: cmd wifi_get ok=1 ssid=- password_set=0 mode=matter
|
||||
W (20258) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (20294) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (20441) wifi:Haven't to connect to a suitable AP now!
|
||||
W (20715) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (21259) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (21295) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (21442) wifi:Haven't to connect to a suitable AP now!
|
||||
W (21716) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (22166) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (22169) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (22170) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (22173) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (22181) wifi:Haven't to connect to a suitable AP now!
|
||||
E (22186) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (22196) wifi:Haven't to connect to a suitable AP now!
|
||||
I (22200) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (22207) wifi:Haven't to connect to a suitable AP now!
|
||||
E (22212) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (22222) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (22226) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (22260) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (22296) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (22327) wifi:Haven't to connect to a suitable AP now!
|
||||
I (22328) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (22332) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (22334) wifi:Haven't to connect to a suitable AP now!
|
||||
E (22340) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (22349) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (22446) wifi:Haven't to connect to a suitable AP now!
|
||||
I (22574) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (22717) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (23075) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (23261) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (23297) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (23447) wifi:Haven't to connect to a suitable AP now!
|
||||
I (23576) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (23718) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (24077) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (24262) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (24299) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (24448) wifi:Haven't to connect to a suitable AP now!
|
||||
I (24570) OTA_CLIENT: HTTP client handle: 0x3fcf02e4
|
||||
I (24578) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (24710) MICROPHONE: Read 600000 bytes in 9940 ms
|
||||
I (24712) MICROPHONE: Read 600000 bytes of audio data
|
||||
W (24719) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (25079) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (25264) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (25300) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (25449) wifi:Haven't to connect to a suitable AP now!
|
||||
I (25580) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (25720) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (26081) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (26265) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (26301) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (26450) wifi:Haven't to connect to a suitable AP now!
|
||||
I (26582) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (26721) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (27083) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (27150) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (27152) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (27154) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (27157) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (27164) wifi:Haven't to connect to a suitable AP now!
|
||||
E (27169) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (27179) wifi:Haven't to connect to a suitable AP now!
|
||||
I (27183) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (27190) wifi:Haven't to connect to a suitable AP now!
|
||||
E (27196) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (27205) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (27209) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (27266) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (27302) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (27310) wifi:Haven't to connect to a suitable AP now!
|
||||
I (27311) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (27315) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (27318) wifi:Haven't to connect to a suitable AP now!
|
||||
E (27323) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (27332) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (27456) wifi:Haven't to connect to a suitable AP now!
|
||||
I (27584) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (27722) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (28085) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (28160) MICROPHONE: Raw block stats: mean=-17.8 min=-146018 max=134018
|
||||
I (28161) MICROPHONE: Sum SQ: 0.578925, RMS pressure: 0.001701, Normalized: 0.001701, dB peak: -55.38
|
||||
W (28166) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (28267) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (28303) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (28457) wifi:Haven't to connect to a suitable AP now!
|
||||
I (28586) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (28723) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (29087) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (29172) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (29268) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (29304) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (29458) wifi:Haven't to connect to a suitable AP now!
|
||||
I (29588) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (29724) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (30089) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (30173) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (30269) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (30305) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (30459) wifi:Haven't to connect to a suitable AP now!
|
||||
I (30590) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (30725) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (31091) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (31174) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (31270) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (31306) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (31460) wifi:Haven't to connect to a suitable AP now!
|
||||
E (31572) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0
|
||||
E (31573) esp-tls: Failed to open new connection
|
||||
E (31575) transport_base: Failed to open a new connection
|
||||
E (31580) HTTP_CLIENT: Connection failed, sock < 0
|
||||
E (31585) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:355
|
||||
I (31592) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
E (31593) OTA_CLIENT: [E-OTA-13] Failed to check for updates: ESP_ERR_HTTP_CONNECT
|
||||
I (31606) KOTA: status OK -> CONNECTION_ERROR reason=ota_check_failed
|
||||
W (31726) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (32098) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (32133) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (32135) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (32137) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (32140) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (32147) wifi:Haven't to connect to a suitable AP now!
|
||||
E (32152) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (32162) wifi:Haven't to connect to a suitable AP now!
|
||||
I (32166) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (32173) wifi:Haven't to connect to a suitable AP now!
|
||||
W (32175) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
E (32179) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (32194) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (32199) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (32271) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (32300) wifi:Haven't to connect to a suitable AP now!
|
||||
I (32301) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (32305) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (32307) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (32307) wifi:Haven't to connect to a suitable AP now!
|
||||
E (32319) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (32328) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (32462) wifi:Haven't to connect to a suitable AP now!
|
||||
I (32599) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (32727) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (32730) chip[DL]: bleAdv Timeout : Start slow advertisement
|
||||
I (32732) chip[DL]: Configuring CHIPoBLE advertising (interval 500 ms, connectable)
|
||||
I (32737) chip[DL]: Device already advertising, stop active advertisement and restart
|
||||
I (32744) NimBLE: GAP procedure initiated: stop advertising.
|
||||
|
||||
I (32750) NimBLE: GAP procedure initiated: advertise;
|
||||
I (32754) NimBLE: disc_mode=2
|
||||
I (32756) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=800 adv_itvl_max=800
|
||||
I (32767) NimBLE:
|
||||
|
||||
I (33100) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (33184) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (33272) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (33320) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (33463) wifi:Haven't to connect to a suitable AP now!
|
||||
I (33601) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (33728) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (34102) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (34185) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (34274) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (34321) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (34464) wifi:Haven't to connect to a suitable AP now!
|
||||
I (34603) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (34729) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (35104) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (35186) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (35275) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (35322) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (35465) wifi:Haven't to connect to a suitable AP now!
|
||||
I (35605) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (35730) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (36106) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (36187) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (36276) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (36323) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (36466) wifi:Haven't to connect to a suitable AP now!
|
||||
I (36607) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (36731) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (37107) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (37122) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (37125) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (37126) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (37129) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (37137) wifi:Haven't to connect to a suitable AP now!
|
||||
E (37142) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (37151) wifi:Haven't to connect to a suitable AP now!
|
||||
I (37156) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (37163) wifi:Haven't to connect to a suitable AP now!
|
||||
E (37168) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (37178) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (37182) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (37188) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (37277) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (37283) wifi:Haven't to connect to a suitable AP now!
|
||||
I (37284) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (37287) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (37290) wifi:Haven't to connect to a suitable AP now!
|
||||
E (37295) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (37305) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (37324) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (37468) wifi:Haven't to connect to a suitable AP now!
|
||||
I (37608) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (37732) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (38109) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (38195) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (38278) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (38325) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (38470) wifi:Haven't to connect to a suitable AP now!
|
||||
I (38610) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (38733) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (39111) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (39196) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (39279) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (39326) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (39471) wifi:Haven't to connect to a suitable AP now!
|
||||
I (39612) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (39736) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (40113) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (40197) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (40280) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (40326) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (40473) wifi:Haven't to connect to a suitable AP now!
|
||||
I (40614) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (40737) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (41115) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (41198) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (41281) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (41327) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (41474) wifi:Haven't to connect to a suitable AP now!
|
||||
I (41616) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (41738) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (42105) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (42108) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (42109) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (42112) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
I (42116) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (42120) wifi:Haven't to connect to a suitable AP now!
|
||||
E (42130) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (42140) wifi:Haven't to connect to a suitable AP now!
|
||||
I (42144) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (42151) wifi:Haven't to connect to a suitable AP now!
|
||||
E (42156) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (42166) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (42170) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (42199) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (42271) wifi:Haven't to connect to a suitable AP now!
|
||||
I (42272) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (42275) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (42278) wifi:Haven't to connect to a suitable AP now!
|
||||
W (42283) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
E (42283) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (42299) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (42328) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (42476) wifi:Haven't to connect to a suitable AP now!
|
||||
I (42625) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (42739) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (43126) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (43200) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (43290) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (43329) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (43482) wifi:Haven't to connect to a suitable AP now!
|
||||
I (43627) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (43740) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (44128) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (44201) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (44291) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (44330) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (44486) wifi:Haven't to connect to a suitable AP now!
|
||||
I (44629) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (44741) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (45130) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (45202) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (45292) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (45331) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (45487) wifi:Haven't to connect to a suitable AP now!
|
||||
I (45631) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (45742) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (46132) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (46203) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (46293) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (46332) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (46488) wifi:Haven't to connect to a suitable AP now!
|
||||
I (46633) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (46743) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (47093) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (47095) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (47097) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (47100) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (47107) wifi:Haven't to connect to a suitable AP now!
|
||||
E (47112) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (47122) wifi:Haven't to connect to a suitable AP now!
|
||||
I (47126) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
I (47134) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (47134) wifi:Haven't to connect to a suitable AP now!
|
||||
E (47144) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (47154) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (47158) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (47204) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (47259) wifi:Haven't to connect to a suitable AP now!
|
||||
I (47260) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (47263) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (47266) wifi:Haven't to connect to a suitable AP now!
|
||||
E (47271) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (47281) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (47294) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (47333) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (47493) wifi:Haven't to connect to a suitable AP now!
|
||||
I (47639) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (47744) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (48140) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (48205) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (48295) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (48334) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (48495) wifi:Haven't to connect to a suitable AP now!
|
||||
I (48641) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (48747) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (49142) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (49206) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (49296) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (49335) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (49496) wifi:Haven't to connect to a suitable AP now!
|
||||
I (49643) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (49748) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (50144) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (50207) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (50297) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (50336) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (50498) wifi:Haven't to connect to a suitable AP now!
|
||||
I (50645) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (50749) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (51146) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (51208) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (51298) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (51337) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (51500) wifi:Haven't to connect to a suitable AP now!
|
||||
I (51647) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (51750) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (52081) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (52084) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (52085) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (52088) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (52096) wifi:Haven't to connect to a suitable AP now!
|
||||
E (52100) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (52110) wifi:Haven't to connect to a suitable AP now!
|
||||
I (52115) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (52122) wifi:Haven't to connect to a suitable AP now!
|
||||
E (52127) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (52136) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (52141) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (52148) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (52209) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (52242) wifi:Haven't to connect to a suitable AP now!
|
||||
I (52243) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (52246) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (52249) wifi:Haven't to connect to a suitable AP now!
|
||||
E (52254) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (52264) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (52299) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (52338) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (52508) wifi:Haven't to connect to a suitable AP now!
|
||||
I (52653) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (52751) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (53154) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (53210) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (53300) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (53339) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (53509) wifi:Haven't to connect to a suitable AP now!
|
||||
I (53655) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (53752) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (54156) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (54212) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (54301) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (54340) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (54510) wifi:Haven't to connect to a suitable AP now!
|
||||
I (54657) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (54753) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (55158) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (55213) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (55302) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (55341) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (55511) wifi:Haven't to connect to a suitable AP now!
|
||||
I (55659) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (55754) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (56160) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (56214) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (56303) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (56342) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (56512) wifi:Haven't to connect to a suitable AP now!
|
||||
I (56661) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (56755) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (57064) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (57067) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (57068) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (57071) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (57079) wifi:Haven't to connect to a suitable AP now!
|
||||
E (57083) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (57093) wifi:Haven't to connect to a suitable AP now!
|
||||
I (57097) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (57105) wifi:Haven't to connect to a suitable AP now!
|
||||
E (57110) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (57119) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (57124) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (57162) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (57215) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (57225) wifi:Haven't to connect to a suitable AP now!
|
||||
I (57226) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (57230) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (57232) wifi:Haven't to connect to a suitable AP now!
|
||||
E (57237) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (57247) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (57304) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (57344) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (57517) wifi:Haven't to connect to a suitable AP now!
|
||||
I (57663) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (57756) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (58164) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (58216) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (58305) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (58345) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (58518) wifi:Haven't to connect to a suitable AP now!
|
||||
I (58665) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (58757) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (59166) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (59217) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (59306) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (59346) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (59519) wifi:Haven't to connect to a suitable AP now!
|
||||
I (59667) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (59758) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (60168) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (60218) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (60307) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (60346) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (60520) wifi:Haven't to connect to a suitable AP now!
|
||||
I (60669) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (60759) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (61170) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (61219) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (61308) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (61347) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (61521) wifi:Haven't to connect to a suitable AP now!
|
||||
I (61671) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (61760) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (62047) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (62050) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (62051) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (62054) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (62062) wifi:Haven't to connect to a suitable AP now!
|
||||
E (62067) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (62076) wifi:Haven't to connect to a suitable AP now!
|
||||
I (62081) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (62088) wifi:Haven't to connect to a suitable AP now!
|
||||
E (62093) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (62102) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (62107) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (62172) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (62208) wifi:Haven't to connect to a suitable AP now!
|
||||
I (62209) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (62212) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (62215) wifi:Haven't to connect to a suitable AP now!
|
||||
W (62220) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
E (62220) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (62236) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (62309) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (62348) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (62527) wifi:Haven't to connect to a suitable AP now!
|
||||
I (62673) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (62761) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (62925) wifi:Haven't to connect to a suitable AP now!
|
||||
I (62925) KOTA: health up=61s status=CONNECTION_ERROR wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=0 heap=47259 psram=7.3M last_upload=never ota=registered
|
||||
I (63174) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (63227) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (63310) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (63349) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (63528) wifi:Haven't to connect to a suitable AP now!
|
||||
I (63675) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (63762) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (64176) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (64228) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (64311) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (64350) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (64529) wifi:Haven't to connect to a suitable AP now!
|
||||
I (64617) SEN66: First measurement, waiting additional 120 seconds for sensor to stabilize...
|
||||
I (64677) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (64763) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (65178) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (65229) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (65312) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (65351) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (65530) wifi:Haven't to connect to a suitable AP now!
|
||||
I (65679) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (65764) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (66180) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (66230) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (66313) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (66352) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (66531) wifi:Haven't to connect to a suitable AP now!
|
||||
I (66681) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (66765) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (67030) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (67033) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (67034) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (67037) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (67045) wifi:Haven't to connect to a suitable AP now!
|
||||
E (67049) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (67059) wifi:Haven't to connect to a suitable AP now!
|
||||
I (67063) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (67071) wifi:Haven't to connect to a suitable AP now!
|
||||
E (67076) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (67085) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (67090) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (67182) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (67191) wifi:Haven't to connect to a suitable AP now!
|
||||
I (67192) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (67195) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (67198) wifi:Haven't to connect to a suitable AP now!
|
||||
E (67203) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (67213) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (67231) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (67314) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (67353) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (67537) wifi:Haven't to connect to a suitable AP now!
|
||||
I (67683) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (67766) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (68184) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (68232) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (68315) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (68354) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (68542) wifi:Haven't to connect to a suitable AP now!
|
||||
I (68685) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (68767) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (69186) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (69233) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (69316) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (69355) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (69546) wifi:Haven't to connect to a suitable AP now!
|
||||
I (69687) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (69768) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (70188) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (70234) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (70317) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (70356) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (70547) wifi:Haven't to connect to a suitable AP now!
|
||||
I (70689) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (70769) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (71190) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (71235) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (71318) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (71357) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (71548) wifi:Haven't to connect to a suitable AP now!
|
||||
I (71691) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (71770) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (72013) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (72015) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (72017) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (72020) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (72027) wifi:Haven't to connect to a suitable AP now!
|
||||
E (72032) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (72042) wifi:Haven't to connect to a suitable AP now!
|
||||
I (72046) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (72054) wifi:Haven't to connect to a suitable AP now!
|
||||
E (72059) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (72068) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (72073) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (72173) wifi:Haven't to connect to a suitable AP now!
|
||||
I (72175) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (72178) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (72181) wifi:Haven't to connect to a suitable AP now!
|
||||
E (72185) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (72191) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (72195) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (72236) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (72319) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (72358) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (72552) wifi:Haven't to connect to a suitable AP now!
|
||||
I (72700) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (72771) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (73201) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (73237) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (73320) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (73359) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (73553) wifi:Haven't to connect to a suitable AP now!
|
||||
I (73702) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (73772) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (74203) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (74238) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (74321) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (74360) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (74555) wifi:Haven't to connect to a suitable AP now!
|
||||
I (74581) SENSOR_GATHERER: 3/4 tasks still running, waiting...
|
||||
I (74627) INFLUX_BUFFER: Buffer file does not exist, returning empty buffer
|
||||
I (74628) INFLUX_BUFFER: Buffer is empty, nothing to flush
|
||||
I (74704) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (74773) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
--- disconnected ---
|
||||
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
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Wi-Fi over USB (firmware usb_console api=1): encoding, replies and the whole
|
||||
write/clear/read job, driven with reply lines in the firmware's exact format."""
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.core.logline import parse_line
|
||||
from kota_tech.diag.engine import DiagnosticsEngine
|
||||
from kota_tech.serialio.commands import (
|
||||
CONNECT_TIMEOUT_S, PING_TIMEOUT_S, Step, WifiFlow, loggable, parse_reply, validate_wifi, wifi_set_command)
|
||||
|
||||
PING = "I (5000) KOTA: cmd ping ok=1 fw=1.0.16 api=1"
|
||||
BOOT = ("I (2843) KOTA: boot fw=1.0.16 build=3ae273ec hw=2 mac=A4CB8FC946F8 reset=RTC_SW_SYS_RST "
|
||||
"mode={mode} commissioned=- last_restart={last}")
|
||||
HEALTH = ("I (9000) KOTA: cmd health ok=1 up=9s status=OK wifi={wifi} rssi={rssi} time_valid=1 sd=ok buffer=0 "
|
||||
"heap=90000 psram=7.6M last_upload=never ota=registered")
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 0.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def make(action, **kw):
|
||||
sent, events, clock = [], [], Clock()
|
||||
flow = WifiFlow(action, lambda c: sent.append(c) or True, events.append, now=clock, **kw)
|
||||
return flow, sent, events, clock
|
||||
|
||||
|
||||
def feed(flow, text):
|
||||
flow.feed(parse_line(text))
|
||||
|
||||
|
||||
# ------------------------------------------------------------- encoding
|
||||
|
||||
def test_wifi_set_command_is_hex_utf8():
|
||||
assert wifi_set_command("Biuro 2.4", "tajne=haslo1") == \
|
||||
"kota wifi set 426975726f20322e34 74616a6e653d6861736c6f31"
|
||||
assert wifi_set_command("Łąka", "zażółć1234").split()[3] == "c581c4856b61"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ssid,password,problem", [
|
||||
("", "12345678", "network name"),
|
||||
("x" * 33, "12345678", "32 bytes"),
|
||||
("net", "1234567", "8-63"),
|
||||
("net", "x" * 64, "8-63"),
|
||||
("net\n", "12345678", "line break"),
|
||||
])
|
||||
def test_validation_matches_firmware_limits(ssid, password, problem):
|
||||
assert any(problem in p for p in validate_wifi(ssid, password))
|
||||
with pytest.raises(ValueError):
|
||||
wifi_set_command(ssid, password)
|
||||
|
||||
|
||||
def test_password_never_logged():
|
||||
cmd = wifi_set_command("Biuro", "sekretne-haslo")
|
||||
assert "sekretne" not in loggable(cmd) and "7365" not in loggable(cmd)
|
||||
assert "Biuro" in loggable(cmd)
|
||||
|
||||
|
||||
def test_reply_parsing():
|
||||
r = parse_reply(parse_line("I (1) KOTA: cmd wifi_set ok=0 err=password_length"))
|
||||
assert r.name == "wifi_set" and not r.ok and "8-63" in r.error
|
||||
assert parse_reply(parse_line("I (1) KOTA: health up=1s")) is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- flows
|
||||
|
||||
def test_write_wifi_happy_path():
|
||||
flow, sent, events, clock = make("set", ssid="Biuro", password="haslo1234")
|
||||
flow.start()
|
||||
assert sent == ["kota ping"]
|
||||
feed(flow, PING)
|
||||
assert sent[-1] == wifi_set_command("Biuro", "haslo1234") and flow.step == Step.SEND
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_set ok=1 restart=1")
|
||||
assert flow.step == Step.RESTART
|
||||
feed(flow, "W (5101) KOTA: restart reason=usb_wifi_set")
|
||||
feed(flow, BOOT.format(mode="wifi", last="usb_wifi_set"))
|
||||
assert flow.step == Step.CONNECT
|
||||
clock.t += 6
|
||||
flow.tick()
|
||||
assert sent[-1] == "kota health"
|
||||
feed(flow, HEALTH.format(wifi="disconnected", rssi=0))
|
||||
assert flow.step == Step.CONNECT
|
||||
feed(flow, HEALTH.format(wifi="connected", rssi=-58))
|
||||
assert flow.step == Step.DONE and "Connected to 'Biuro'" in events[-1].text and "-58" in events[-1].text
|
||||
|
||||
|
||||
def test_old_firmware_without_console_times_out_on_ping():
|
||||
flow, sent, events, clock = make("read")
|
||||
flow.start()
|
||||
clock.t += PING_TIMEOUT_S + 0.1
|
||||
flow.tick()
|
||||
assert flow.step == Step.FAILED and "USB console" in events[-1].text
|
||||
|
||||
|
||||
def test_box_rejects_settings():
|
||||
flow, sent, events, _ = make("set", ssid="Biuro", password="haslo1234")
|
||||
flow.start()
|
||||
feed(flow, PING)
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_set ok=0 err=sd_not_mounted")
|
||||
assert flow.step == Step.FAILED and "SD card" in events[-1].text
|
||||
|
||||
|
||||
def test_wrong_password_does_not_connect():
|
||||
flow, _, events, clock = make("set", ssid="Biuro", password="zlehaslo1")
|
||||
flow.start()
|
||||
feed(flow, PING)
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_set ok=1 restart=1")
|
||||
feed(flow, BOOT.format(mode="wifi", last="usb_wifi_set"))
|
||||
clock.t += CONNECT_TIMEOUT_S + 1
|
||||
flow.tick()
|
||||
assert flow.step == Step.FAILED and "2.4 GHz" in events[-1].text
|
||||
|
||||
|
||||
def test_clear_returns_to_matter():
|
||||
flow, sent, events, _ = make("clear")
|
||||
flow.start()
|
||||
feed(flow, PING)
|
||||
assert sent[-1] == "kota wifi clear"
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_clear ok=1 restart=1")
|
||||
feed(flow, BOOT.format(mode="matter", last="usb_wifi_clear"))
|
||||
assert flow.step == Step.DONE and "Matter" in events[-1].text
|
||||
|
||||
|
||||
def test_read_current_settings():
|
||||
flow, sent, events, _ = make("read")
|
||||
flow.start()
|
||||
feed(flow, PING)
|
||||
assert sent[-1] == "kota wifi get"
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_get ok=1 ssid=426975726f password_set=1 mode=wifi")
|
||||
assert flow.step == Step.DONE and flow.info.ssid == "Biuro" and flow.info.password_set
|
||||
assert "local Wi-Fi" in events[-1].text
|
||||
|
||||
|
||||
def test_not_connected():
|
||||
events = []
|
||||
flow = WifiFlow("read", lambda c: False, events.append)
|
||||
flow.start()
|
||||
assert flow.step == Step.FAILED and "not connected" in events[-1].text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- engine
|
||||
|
||||
def test_engine_treats_usb_wifi_restart_as_intentional_and_reads_replies():
|
||||
e = DiagnosticsEngine()
|
||||
for t in ["ESP-ROM:esp32s3-20210327", "I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341",
|
||||
BOOT.format(mode="matter", last="none"), PING,
|
||||
"I (5100) KOTA: cmd wifi_set ok=1 restart=1", "W (5101) KOTA: restart reason=usb_wifi_set",
|
||||
"ESP-ROM:esp32s3-20210327", "rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT)",
|
||||
BOOT.format(mode="wifi", last="usb_wifi_set"),
|
||||
"I (6000) KOTA: cmd wifi_get ok=1 ssid=426975726f password_set=1 mode=wifi",
|
||||
HEALTH.format(wifi="connected", rssi=-60)]:
|
||||
e.feed(parse_line(t))
|
||||
r = e.report()
|
||||
assert not any(f.rule_id.startswith("reboot.") for rc in r.roots for f in [rc.finding, *rc.consequences])
|
||||
assert e.console_api == 1
|
||||
box = dict((t, x) for t, x, _ in r.box)
|
||||
assert "network 'Biuro'" in box["Wi-Fi config"] and "Wi-Fi connected (-60 dBm)" in box["Health"]
|
||||
assert any(m == "wifi_connected" and t is not None for m, _, t in r.milestones)
|
||||
|
||||
|
||||
def test_live_v13_box_answers_usb_commands():
|
||||
# Real capture (2026-09-29): box 14:C1:9F:57:72:48 after flashing merged-binary-v13.bin App-only;
|
||||
# the Wi-Fi tab's read flow sent "kota ping" and "kota wifi get".
|
||||
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_14C19F577248_v13_usb_console.txt"):
|
||||
e.feed(ln)
|
||||
r = e.report()
|
||||
assert e.firmware.elf_sha.startswith("3ae273ec") and e.console_api == 1
|
||||
assert dict((t, x) for t, x, _ in r.box)["Wi-Fi config"] == "no network on the SD card, mode matter"
|
||||
assert not any(f.rule_id.startswith("other.") for f in e.findings.values()) # logs intact via the driver
|
||||
+30
-6
@@ -43,16 +43,20 @@ APP = ["I (1299) app_init: Project name: kota-sensorboard",
|
||||
# ------------------------------------------------------------------ rules
|
||||
|
||||
def test_rules_load_and_have_no_cycles():
|
||||
rules = load_rules()
|
||||
by_id = {r.id: r for r in rules}
|
||||
for r in rules:
|
||||
seen, todo = set(), list(r.explained_by)
|
||||
from kota_tech.diag import kota
|
||||
graph = {r.id: r.explained_by for r in load_rules()}
|
||||
for st in ("ERROR", "OUT_OF_RESOURCES", "CONNECTION_ERROR", "RTC_ERROR", "SD_ERROR", "RESETING", "HTTP_SERVER"):
|
||||
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:
|
||||
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:
|
||||
seen.add(rid)
|
||||
todo += by_id[rid].explained_by
|
||||
todo += graph.get(rid, ())
|
||||
|
||||
|
||||
# --------------------------------------------------------- real captures
|
||||
@@ -233,3 +237,23 @@ def test_session_log_format(tmp_path):
|
||||
for ln in lines:
|
||||
e.feed(ln)
|
||||
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()
|
||||
|
||||
|
||||
def test_mic_disabled_switch_on_verified(rig):
|
||||
def test_mic_switch_flip_verified_without_restart(rig):
|
||||
engine, runner, clock, actions, board, _ = rig
|
||||
_bench(engine)
|
||||
runner.start("mic")
|
||||
assert runner.step.id == "switch"
|
||||
runner.answer("0") # set it to ON
|
||||
assert actions == ["reset"]
|
||||
board(*BOOT, "I (20000) MICROPHONE: Read 300000 bytes in 4936 ms")
|
||||
assert "above the power switch" in runner.view().text
|
||||
runner.answer("0") # flipped the external switch
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
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)
|
||||
assert not bt.is_sensorbox and bt.mac is None
|
||||
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
|
||||
@@ -0,0 +1,19 @@
|
||||
"""The whole window builds (catches errors that only show when the app starts)."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
|
||||
def test_main_window_builds_with_all_tabs():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from kota_tech.ui.main_window import MainWindow
|
||||
app = QApplication.instance() or QApplication([])
|
||||
w = MainWindow()
|
||||
tabs = w.centralWidget()
|
||||
assert [tabs.tabText(i) for i in range(tabs.count())] == \
|
||||
["Quick start", "Serial monitor", "Flash firmware", "Wi-Fi (USB)", "SD card"]
|
||||
assert not w.wifi_tab.write_btn.isEnabled() # nothing connected, nothing typed
|
||||
w.serial_tab.shutdown()
|
||||
app.processEvents()
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# KOTA Sensorbox Technician Tool - update to the newest released version.
|
||||
# Checks Gitea for the newest release tag (vX.Y) and downloads only what changed (git).
|
||||
# Works in a git clone and in a folder unpacked from a release zip (it is connected on first run).
|
||||
# If Finder refuses to open this file, run it once as: bash update_macos.command
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
REPO_URL="${KOTA_REPO_URL:-https://gitea.kota-app.com/kkallas/kota-technician-tool.git}"
|
||||
export UV_PROJECT_ENVIRONMENT="${UV_PROJECT_ENVIRONMENT:-$HOME/Library/Caches/KotaTechnicianTool/venv}"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
finish() {
|
||||
[ -z "$KOTA_NO_PAUSE" ] && [ -t 0 ] && read -r -p "Press Enter to close..." _
|
||||
exit "$1"
|
||||
}
|
||||
fail() { echo; echo "[!] $*"; echo "[!] Update did not finish."; finish 1; }
|
||||
|
||||
# ---- git (macOS: the Command Line Tools installer offers it) --------------
|
||||
if ! git --version >/dev/null 2>&1; then
|
||||
if [ "$(uname)" = Darwin ]; then
|
||||
xcode-select --install >/dev/null 2>&1
|
||||
fail "Git is being installed (Apple Command Line Tools). Run this again when the installer has finished."
|
||||
fi
|
||||
fail "Git is not installed."
|
||||
fi
|
||||
|
||||
# ---- connect a folder unpacked from a zip ----------------------------------
|
||||
if [ ! -d .git ]; then
|
||||
echo "Connecting this folder to $REPO_URL ..."
|
||||
git init -q && git remote add origin "$REPO_URL" || fail "Cannot set up git in this folder."
|
||||
fi
|
||||
|
||||
# A developer copy follows a branch; it is updated with "git pull", not here.
|
||||
if git rev-parse -q --verify HEAD >/dev/null 2>&1 && branch=$(git symbolic-ref -q --short HEAD); then
|
||||
echo "This is a developer copy on branch $branch. Update it with: git pull"
|
||||
finish 0
|
||||
fi
|
||||
|
||||
echo "Checking for a new version..."
|
||||
git fetch --quiet --force --tags origin ||
|
||||
fail "Cannot reach $REPO_URL. Check the Internet connection and your Gitea login, then try again."
|
||||
|
||||
latest=$(git tag -l 'v*' --sort=-v:refname | head -n 1)
|
||||
[ -n "$latest" ] || fail "No released version found on the server."
|
||||
current=$(git describe --tags --exact-match HEAD 2>/dev/null)
|
||||
|
||||
if [ "$current" = "$latest" ]; then
|
||||
echo "You already have the newest version $latest."
|
||||
else
|
||||
if [ -n "$current" ]; then
|
||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
echo "Some tool files in this folder were changed by hand. They will be replaced by $latest."
|
||||
read -r -p "Continue? [y/N] " answer
|
||||
[[ "$answer" =~ ^[Yy]$ ]] || fail "Cancelled."
|
||||
fi
|
||||
echo "Updating $current -> $latest. Changes:"
|
||||
git --no-pager log --oneline --no-decorate "$current..$latest"
|
||||
else
|
||||
echo "Installing $latest..."
|
||||
fi
|
||||
git -c advice.detachedHead=false checkout -q -f "$latest" || fail "Checkout of $latest failed."
|
||||
echo "Now on $latest."
|
||||
fi
|
||||
|
||||
# ---- Python libraries (only what changed) ----------------------------------
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "Installing uv (Python package runner, current user only)..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh || fail "uv installation failed. Ask the firmware team for help."
|
||||
fi
|
||||
echo "Checking Python libraries..."
|
||||
uv sync --frozen --no-dev --python 3.12 --quiet || fail "Installing Python libraries failed."
|
||||
echo
|
||||
echo "Done. Start the tool with run_macos.command."
|
||||
finish 0
|
||||
@@ -0,0 +1,107 @@
|
||||
@echo off
|
||||
rem KOTA Sensorbox Technician Tool - update to the newest released version.
|
||||
rem Checks Gitea for the newest release tag (vX.Y) and downloads only what changed (git).
|
||||
rem Works in a git clone and in a folder unpacked from a release zip (it is connected on first run).
|
||||
setlocal EnableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
title KOTA Technician Tool - update
|
||||
|
||||
if not defined KOTA_REPO_URL set "KOTA_REPO_URL=https://gitea.kota-app.com/kkallas/kota-technician-tool.git"
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
|
||||
set "PATH=%USERPROFILE%\.local\bin;%ProgramFiles%\Git\cmd;%PATH%"
|
||||
|
||||
rem ---- git ----------------------------------------------------------------
|
||||
where git >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Git is not installed. It is needed to download updates.
|
||||
choice /m "Install Git for Windows now"
|
||||
if errorlevel 2 goto :fail
|
||||
winget install --id Git.Git -e --source winget --accept-package-agreements
|
||||
where git >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Git installation failed. Install it from https://git-scm.com and run this again.
|
||||
goto :fail
|
||||
)
|
||||
)
|
||||
|
||||
rem ---- connect a folder unpacked from a zip ---------------------------------
|
||||
if not exist ".git" (
|
||||
echo Connecting this folder to %KOTA_REPO_URL% ...
|
||||
git init -q || goto :fail
|
||||
git remote add origin "%KOTA_REPO_URL%" || goto :fail
|
||||
)
|
||||
|
||||
rem A developer copy follows a branch; it is updated with "git pull", not here.
|
||||
git rev-parse -q --verify HEAD >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
for /f "delims=" %%b in ('git symbolic-ref -q --short HEAD 2^>nul') do (
|
||||
echo This is a developer copy on branch %%b. Update it with: git pull
|
||||
goto :done
|
||||
)
|
||||
)
|
||||
|
||||
echo Checking for a new version...
|
||||
git fetch --quiet --force --tags origin
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Cannot reach %KOTA_REPO_URL%.
|
||||
echo Check the Internet connection and your Gitea login, then try again.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
set "LATEST="
|
||||
for /f "delims=" %%t in ('git tag -l "v*" --sort^=-v:refname') do if not defined LATEST set "LATEST=%%t"
|
||||
if not defined LATEST (
|
||||
echo ERROR: No released version found on the server.
|
||||
goto :fail
|
||||
)
|
||||
set "CURRENT="
|
||||
for /f "delims=" %%t in ('git describe --tags --exact-match HEAD 2^>nul') do set "CURRENT=%%t"
|
||||
|
||||
if "%CURRENT%"=="%LATEST%" (
|
||||
echo You already have the newest version %LATEST%.
|
||||
goto :deps
|
||||
)
|
||||
|
||||
if defined CURRENT (
|
||||
for /f "delims=" %%s in ('git status --porcelain --untracked-files^=no') do set "DIRTY=1"
|
||||
if defined DIRTY (
|
||||
echo Some tool files in this folder were changed by hand. They will be replaced by %LATEST%.
|
||||
choice /m "Continue"
|
||||
if errorlevel 2 goto :fail
|
||||
)
|
||||
echo Updating %CURRENT% -^> %LATEST%. Changes:
|
||||
git --no-pager log --oneline --no-decorate "%CURRENT%..%LATEST%"
|
||||
) else (
|
||||
echo Installing %LATEST%...
|
||||
)
|
||||
git -c advice.detachedHead=false checkout -q -f "%LATEST%"
|
||||
if errorlevel 1 goto :fail
|
||||
echo Now on %LATEST%.
|
||||
|
||||
rem ---- Python libraries (only what changed) ----------------------------------
|
||||
:deps
|
||||
where uv >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing uv ^(Python package runner, current user only^)...
|
||||
powershell -NoProfile -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
)
|
||||
where uv >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: uv installation failed. Ask the firmware team for help.
|
||||
goto :fail
|
||||
)
|
||||
echo Checking Python libraries...
|
||||
uv sync --frozen --no-dev --python 3.12 --quiet
|
||||
if errorlevel 1 goto :fail
|
||||
echo.
|
||||
echo Done. Start the tool with run_windows.bat.
|
||||
|
||||
:done
|
||||
if not defined KOTA_NO_PAUSE pause
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo ERROR: Update did not finish.
|
||||
if not defined KOTA_NO_PAUSE pause
|
||||
exit /b 1
|
||||
@@ -1,6 +1,138 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
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]]
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "iniconfig"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.1.0"
|
||||
version = "0.9.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "esptool" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyside6-essentials" },
|
||||
{ name = "segno" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyfatfs" },
|
||||
{ name = "pytest" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "esptool", specifier = "~=4.8" },
|
||||
{ name = "pyserial", specifier = ">=3.5" },
|
||||
{ name = "pyside6-essentials", specifier = ">=6.7" },
|
||||
{ name = "segno", specifier = ">=1.6" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
dev = [
|
||||
{ name = "pyfatfs", specifier = ">=1.0" },
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "setuptools", specifier = "<81" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pygments"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "shiboken6"
|
||||
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/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