Compare commits
4
Commits
3fe45fa4d5
...
6a62beb6aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a62beb6aa | ||
|
|
5bd14e1d5b | ||
|
|
ab420220e1 | ||
|
|
0d21c5c205 |
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
|
||||
@@ -1,77 +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.
|
||||
|
||||
## 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.
|
||||
# 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).
|
||||
Binary file not shown.
+202
-202
@@ -1,202 +1,202 @@
|
||||
"""Serial byte stream -> framed, cleaned, parsed log lines.
|
||||
|
||||
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
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from .redact import redact
|
||||
|
||||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
|
||||
# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then
|
||||
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
|
||||
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
|
||||
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
|
||||
# "[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\.\.\.$"
|
||||
)
|
||||
|
||||
|
||||
class LineKind(str, Enum):
|
||||
LOG = "log" # ESP_LOGx line
|
||||
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, ...)
|
||||
|
||||
|
||||
def host_event(text: str) -> LogLine:
|
||||
return LogLine(f"--- {text} ---", LineKind.HOST)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LogLine:
|
||||
text: str # cleaned + redacted
|
||||
kind: LineKind
|
||||
host_time: float = field(default_factory=time.time)
|
||||
level: str | None = None # E/W/I/D/V for LOG lines
|
||||
uptime_ms: int | None = None
|
||||
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:
|
||||
return ANSI_RE.sub("", raw).replace("\r", "").rstrip()
|
||||
|
||||
|
||||
def parse_line(raw: str, host_time: float | None = None) -> LogLine:
|
||||
text = redact(clean(raw))
|
||||
t = time.time() if host_time is None else host_time
|
||||
if m := ESP_LOG_RE.match(text):
|
||||
level, ms, tag, msg = m.groups()
|
||||
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):
|
||||
return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2))
|
||||
if PANIC_RE.search(text):
|
||||
return LogLine(text, LineKind.PANIC, t)
|
||||
return LogLine(text, LineKind.RAW, t)
|
||||
|
||||
|
||||
class LineFramer:
|
||||
"""Accumulates bytes and yields complete lines.
|
||||
|
||||
A partial line is released after `idle_flush_s` without new bytes so
|
||||
prompts / unterminated output are not held back forever.
|
||||
|
||||
Lines are also split where a new log line starts in the middle
|
||||
("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores
|
||||
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old
|
||||
line while no host reads, releasing it glued in front of the next live
|
||||
output. After a (re)open, the fragment in front of the first such split
|
||||
is stale and dropped. Timing cannot identify it, because the FIFO only
|
||||
flushes when the board next prints, possibly seconds later.
|
||||
"""
|
||||
|
||||
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
|
||||
drop_first_partial: bool = False):
|
||||
self._buf = bytearray()
|
||||
self._last_rx = 0.0
|
||||
self.idle_flush_s = idle_flush_s
|
||||
self.max_line = max_line
|
||||
self._first_line_pending = drop_first_partial
|
||||
|
||||
def cancel_drop(self) -> None:
|
||||
self._first_line_pending = False
|
||||
|
||||
def feed(self, data: bytes, now: float | None = None) -> list[str]:
|
||||
now = time.monotonic() if now is None else now
|
||||
if data:
|
||||
self._buf.extend(data)
|
||||
self._last_rx = now
|
||||
out: list[str] = []
|
||||
while (i := self._buf.find(b"\n")) >= 0:
|
||||
out += self._emit(self._buf[:i])
|
||||
del self._buf[: i + 1]
|
||||
if len(self._buf) >= self.max_line:
|
||||
out += self._emit(self._buf)
|
||||
self._buf.clear()
|
||||
return out
|
||||
|
||||
def poll_idle(self, now: float | None = None) -> list[str]:
|
||||
now = time.monotonic() if now is None else now
|
||||
if self._buf and now - self._last_rx >= self.idle_flush_s:
|
||||
return self.flush()
|
||||
return []
|
||||
|
||||
def flush(self) -> list[str]:
|
||||
if not self._buf:
|
||||
return []
|
||||
out = self._emit(self._buf)
|
||||
self._buf.clear()
|
||||
return out
|
||||
|
||||
def _emit(self, raw: bytes | bytearray) -> list[str]:
|
||||
parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
|
||||
if self._first_line_pending:
|
||||
self._first_line_pending = False
|
||||
if len(parts) > 1:
|
||||
parts = parts[1:] # stale FIFO fragment from before the open
|
||||
return parts
|
||||
|
||||
|
||||
# Start of a log line (optionally ANSI-coloured) or ROM banner.
|
||||
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)")
|
||||
|
||||
|
||||
def split_glued(text: str) -> list[str]:
|
||||
# Left-to-right scan so an ANSI colour prefix stays with its line.
|
||||
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0]
|
||||
if not cuts:
|
||||
return [text]
|
||||
bounds = [0, *cuts, len(text)]
|
||||
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b]
|
||||
"""Serial byte stream -> framed, cleaned, parsed log lines.
|
||||
|
||||
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
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from .redact import redact
|
||||
|
||||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
|
||||
# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then
|
||||
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
|
||||
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
|
||||
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
|
||||
# "[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\.\.\.$"
|
||||
)
|
||||
|
||||
|
||||
class LineKind(str, Enum):
|
||||
LOG = "log" # ESP_LOGx line
|
||||
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, ...)
|
||||
|
||||
|
||||
def host_event(text: str) -> LogLine:
|
||||
return LogLine(f"--- {text} ---", LineKind.HOST)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LogLine:
|
||||
text: str # cleaned + redacted
|
||||
kind: LineKind
|
||||
host_time: float = field(default_factory=time.time)
|
||||
level: str | None = None # E/W/I/D/V for LOG lines
|
||||
uptime_ms: int | None = None
|
||||
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:
|
||||
return ANSI_RE.sub("", raw).replace("\r", "").rstrip()
|
||||
|
||||
|
||||
def parse_line(raw: str, host_time: float | None = None) -> LogLine:
|
||||
text = redact(clean(raw))
|
||||
t = time.time() if host_time is None else host_time
|
||||
if m := ESP_LOG_RE.match(text):
|
||||
level, ms, tag, msg = m.groups()
|
||||
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):
|
||||
return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2))
|
||||
if PANIC_RE.search(text):
|
||||
return LogLine(text, LineKind.PANIC, t)
|
||||
return LogLine(text, LineKind.RAW, t)
|
||||
|
||||
|
||||
class LineFramer:
|
||||
"""Accumulates bytes and yields complete lines.
|
||||
|
||||
A partial line is released after `idle_flush_s` without new bytes so
|
||||
prompts / unterminated output are not held back forever.
|
||||
|
||||
Lines are also split where a new log line starts in the middle
|
||||
("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores
|
||||
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old
|
||||
line while no host reads, releasing it glued in front of the next live
|
||||
output. After a (re)open, the fragment in front of the first such split
|
||||
is stale and dropped. Timing cannot identify it, because the FIFO only
|
||||
flushes when the board next prints, possibly seconds later.
|
||||
"""
|
||||
|
||||
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
|
||||
drop_first_partial: bool = False):
|
||||
self._buf = bytearray()
|
||||
self._last_rx = 0.0
|
||||
self.idle_flush_s = idle_flush_s
|
||||
self.max_line = max_line
|
||||
self._first_line_pending = drop_first_partial
|
||||
|
||||
def cancel_drop(self) -> None:
|
||||
self._first_line_pending = False
|
||||
|
||||
def feed(self, data: bytes, now: float | None = None) -> list[str]:
|
||||
now = time.monotonic() if now is None else now
|
||||
if data:
|
||||
self._buf.extend(data)
|
||||
self._last_rx = now
|
||||
out: list[str] = []
|
||||
while (i := self._buf.find(b"\n")) >= 0:
|
||||
out += self._emit(self._buf[:i])
|
||||
del self._buf[: i + 1]
|
||||
if len(self._buf) >= self.max_line:
|
||||
out += self._emit(self._buf)
|
||||
self._buf.clear()
|
||||
return out
|
||||
|
||||
def poll_idle(self, now: float | None = None) -> list[str]:
|
||||
now = time.monotonic() if now is None else now
|
||||
if self._buf and now - self._last_rx >= self.idle_flush_s:
|
||||
return self.flush()
|
||||
return []
|
||||
|
||||
def flush(self) -> list[str]:
|
||||
if not self._buf:
|
||||
return []
|
||||
out = self._emit(self._buf)
|
||||
self._buf.clear()
|
||||
return out
|
||||
|
||||
def _emit(self, raw: bytes | bytearray) -> list[str]:
|
||||
parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
|
||||
if self._first_line_pending:
|
||||
self._first_line_pending = False
|
||||
if len(parts) > 1:
|
||||
parts = parts[1:] # stale FIFO fragment from before the open
|
||||
return parts
|
||||
|
||||
|
||||
# Start of a log line (optionally ANSI-coloured) or ROM banner.
|
||||
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)")
|
||||
|
||||
|
||||
def split_glued(text: str) -> list[str]:
|
||||
# Left-to-right scan so an ANSI colour prefix stays with its line.
|
||||
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0]
|
||||
if not cuts:
|
||||
return [text]
|
||||
bounds = [0, *cuts, len(text)]
|
||||
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b]
|
||||
+1021
-999
File diff suppressed because it is too large.
Load diff
@@ -191,3 +191,4 @@ E-GATH-01,GATH,error,Failed to take line protocol mutex,Internal error - send th
|
||||
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
|
||||
|
+147
-147
@@ -1,147 +1,147 @@
|
||||
"""Firmware identification from the boot log.
|
||||
|
||||
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
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnownFirmware:
|
||||
name: str
|
||||
source: str
|
||||
sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT
|
||||
setup_portal: bool # short press -> Wi-Fi setup portal
|
||||
|
||||
|
||||
KNOWN: dict[str, KnownFirmware] = {
|
||||
"45fa9d5b9": KnownFirmware(
|
||||
name="v1.0 (built Jun 29 2026)",
|
||||
source="factory image new_system.bin",
|
||||
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>.+)$")
|
||||
_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)")
|
||||
# Evidence of features, for builds not in KNOWN.
|
||||
_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ")
|
||||
_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ")
|
||||
# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag.
|
||||
_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareInfo:
|
||||
elf_sha: str | None = None
|
||||
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")
|
||||
elif m := _VER.search(text):
|
||||
self.version = m.group("v")
|
||||
elif m := _BUILT.search(text):
|
||||
self.built = m.group("t").strip()
|
||||
elif m := _IDF.search(text):
|
||||
self.idf = m.group("v")
|
||||
if _FEATURE_SD_WIFI.search(text):
|
||||
self.evidence.add("sd_wifi")
|
||||
if _FEATURE_PORTAL.search(text):
|
||||
self.evidence.add("setup_portal")
|
||||
if _LEGACY_SD_TAG.search(text):
|
||||
self.evidence.add("legacy_v1")
|
||||
|
||||
@property
|
||||
def known(self) -> KnownFirmware | None:
|
||||
if not self.elf_sha:
|
||||
return None
|
||||
return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None)
|
||||
|
||||
@property
|
||||
def supports_sd_wifi(self) -> bool | None:
|
||||
"""True/False when determinable, None when unknown (no boot seen)."""
|
||||
if k := self.known:
|
||||
return k.sd_wifi
|
||||
if "sd_wifi" in self.evidence or "kota" in self.evidence:
|
||||
return True
|
||||
if "legacy_v1" in self.evidence:
|
||||
return False
|
||||
return None
|
||||
|
||||
def describe(self) -> str:
|
||||
if k := self.known:
|
||||
return f"{k.name} - {k.source}"
|
||||
if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)):
|
||||
local = local_build(self.elf_sha)
|
||||
built = self.built or (local[1] if local else None)
|
||||
text = f"v{self.version or (local[0] if local else '?')} (build {self.elf_sha[:8]}"
|
||||
text += f", compiled {built})" if built else ")"
|
||||
if self.hw:
|
||||
text += f", hardware {self.hw}"
|
||||
if local:
|
||||
text += f" - same as firmware/{local[2]}"
|
||||
return text
|
||||
if self.elf_sha:
|
||||
built = f", built {self.built}" if self.built else ""
|
||||
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
|
||||
if "legacy_v1" in self.evidence:
|
||||
return "Probably v1.0 factory image (press Reset & capture to confirm)"
|
||||
return "Not identified yet - press Reset & capture"
|
||||
"""Firmware identification from the boot log.
|
||||
|
||||
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
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnownFirmware:
|
||||
name: str
|
||||
source: str
|
||||
sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT
|
||||
setup_portal: bool # short press -> Wi-Fi setup portal
|
||||
|
||||
|
||||
KNOWN: dict[str, KnownFirmware] = {
|
||||
"45fa9d5b9": KnownFirmware(
|
||||
name="v1.0 (built Jun 29 2026)",
|
||||
source="factory image new_system.bin",
|
||||
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>.+)$")
|
||||
_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)")
|
||||
# Evidence of features, for builds not in KNOWN.
|
||||
_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ")
|
||||
_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ")
|
||||
# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag.
|
||||
_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareInfo:
|
||||
elf_sha: str | None = None
|
||||
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")
|
||||
elif m := _VER.search(text):
|
||||
self.version = m.group("v")
|
||||
elif m := _BUILT.search(text):
|
||||
self.built = m.group("t").strip()
|
||||
elif m := _IDF.search(text):
|
||||
self.idf = m.group("v")
|
||||
if _FEATURE_SD_WIFI.search(text):
|
||||
self.evidence.add("sd_wifi")
|
||||
if _FEATURE_PORTAL.search(text):
|
||||
self.evidence.add("setup_portal")
|
||||
if _LEGACY_SD_TAG.search(text):
|
||||
self.evidence.add("legacy_v1")
|
||||
|
||||
@property
|
||||
def known(self) -> KnownFirmware | None:
|
||||
if not self.elf_sha:
|
||||
return None
|
||||
return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None)
|
||||
|
||||
@property
|
||||
def supports_sd_wifi(self) -> bool | None:
|
||||
"""True/False when determinable, None when unknown (no boot seen)."""
|
||||
if k := self.known:
|
||||
return k.sd_wifi
|
||||
if "sd_wifi" in self.evidence or "kota" in self.evidence:
|
||||
return True
|
||||
if "legacy_v1" in self.evidence:
|
||||
return False
|
||||
return None
|
||||
|
||||
def describe(self) -> str:
|
||||
if k := self.known:
|
||||
return f"{k.name} - {k.source}"
|
||||
if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)):
|
||||
local = local_build(self.elf_sha)
|
||||
built = self.built or (local[1] if local else None)
|
||||
text = f"v{self.version or (local[0] if local else '?')} (build {self.elf_sha[:8]}"
|
||||
text += f", compiled {built})" if built else ")"
|
||||
if self.hw:
|
||||
text += f", hardware {self.hw}"
|
||||
if local:
|
||||
text += f" - same as firmware/{local[2]}"
|
||||
return text
|
||||
if self.elf_sha:
|
||||
built = f", built {self.built}" if self.built else ""
|
||||
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
|
||||
if "legacy_v1" in self.evidence:
|
||||
return "Probably v1.0 factory image (press Reset & capture to confirm)"
|
||||
return "Not identified yet - press Reset & capture"
|
||||
+401
-401
@@ -1,401 +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", "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 + "}"
|
||||
"""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 + "}"
|
||||
+740
-740
File diff suppressed because it is too large.
Load diff
@@ -39,6 +39,7 @@ I2C_DEVICES = {"0x39": "AS7341", "0x68": "DS1307", "0x6B": "SEN66", "0x76": "BME
|
||||
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 = {
|
||||
|
||||
+732
-732
File diff suppressed because it is too large.
Load diff
+260
-260
@@ -1,260 +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)
|
||||
"""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)
|
||||
+113
-113
@@ -1,113 +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
|
||||
"""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
|
||||
+261
-261
@@ -1,261 +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)
|
||||
"""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,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)
|
||||
+155
-155
@@ -1,155 +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"))
|
||||
"""Hand-off of a diagnosis to the KOTA service system (Google Apps Script).
|
||||
|
||||
The tool opens the box's service page in the technician's browser:
|
||||
<base><MAC>#kota-report=<base64url(JSON)>
|
||||
The service page (v1.1.0+) reads the fragment and pre-fills status and note;
|
||||
the technician checks it, adds the location and saves - so the report is
|
||||
authored by their own Google login and the tool holds no credentials.
|
||||
The fragment is never sent to any server (redirect host or Apps Script).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
from .. import __version__
|
||||
from ..diag.engine import Report, Severity
|
||||
|
||||
DEFAULT_BASE = "https://service.kota-app.com/"
|
||||
NOTE_LIMIT = 4800 # the service form and Code.gs accept 5000; leave room for the technician
|
||||
|
||||
# Status names exactly as in the service system (Code.gs STATUSES).
|
||||
OK = "Działa poprawnie"
|
||||
CHECK = "Do sprawdzenia"
|
||||
IN_SERVICE = "W trakcie serwisu"
|
||||
HARDWARE = "Błąd hardware"
|
||||
SOFTWARE = "Błąd software"
|
||||
POWER = "Problem z zasilaniem"
|
||||
CONNECTIVITY = "Problem z łącznością"
|
||||
|
||||
# Root-finding rule id prefix -> service status (first match wins).
|
||||
_STATUS_BY_RULE = [
|
||||
("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER),
|
||||
("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE),
|
||||
("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
|
||||
("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
|
||||
("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE),
|
||||
("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE),
|
||||
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
|
||||
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
|
||||
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
|
||||
# firmware 1.0.16+ (KOTA: lines, status, error IDs)
|
||||
("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
|
||||
("kota.low_heap", SOFTWARE),
|
||||
("kota.i2c_stuck", HARDWARE), ("kota.sensor_missing", HARDWARE), ("kota.rtc_", HARDWARE),
|
||||
("kota.sd_", HARDWARE), ("status.error", HARDWARE), ("status.rtc", HARDWARE), ("status.sd", HARDWARE),
|
||||
("kota.wifi_down", CONNECTIVITY), ("kota.weak_wifi", CONNECTIVITY), ("kota.no_upload", CONNECTIVITY),
|
||||
("kota.ota_unregistered", CONNECTIVITY), ("kota.buffer_backlog", CONNECTIVITY), ("status.conn", CONNECTIVITY),
|
||||
("kota.time_invalid", CHECK),
|
||||
*((f"eid.{m}", HARDWARE) for m in ("SD", "ENV", "RTC", "BME", "SEN", "AS", "MIC", "FLK", "SPI")),
|
||||
*((f"eid.{m}", CONNECTIVITY) for m in ("INF", "WIFI", "OTA", "PROV")),
|
||||
("eid.", SOFTWARE),
|
||||
("rtc.clock_not_running", CHECK), ("reboot.", CHECK),
|
||||
]
|
||||
_MAC = re.compile(r"^[0-9A-F]{12}$")
|
||||
|
||||
|
||||
def normalize_mac(mac: str) -> str:
|
||||
return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper()
|
||||
|
||||
|
||||
def service_status(report: Report) -> str:
|
||||
"""Suggested status; the technician can change it in the form."""
|
||||
problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING]
|
||||
if not problems:
|
||||
return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK
|
||||
rid = problems[0].finding.rule_id
|
||||
for prefix, status in _STATUS_BY_RULE:
|
||||
if rid.startswith(prefix):
|
||||
return status
|
||||
return CHECK
|
||||
|
||||
|
||||
BOX_MARK = {"ok": "✔", "warn": "!", "bad": "✖", "info": "i"} # same marks as the Diagnosis panel
|
||||
SD_WIFI = {True: "reads Wi-Fi settings from the SD card",
|
||||
False: "does not read Wi-Fi settings from the SD card",
|
||||
None: "Wi-Fi-from-SD support unknown"}
|
||||
|
||||
|
||||
def service_note(report: Report, limit: int = NOTE_LIMIT) -> str:
|
||||
"""The Diagnosis panel as text: verdict, LED, firmware, box self-report,
|
||||
start-up progress with times, findings with details and when they were seen.
|
||||
|
||||
Falls back to shorter finding details when the full text does not fit the
|
||||
service form (consequences' explanations first, then all), then truncates.
|
||||
"""
|
||||
for detail in ("all", "roots", "none"):
|
||||
text = _note(report, detail)
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit - 20].rstrip() + "\n...(shortened)"
|
||||
|
||||
|
||||
def _note(report: Report, detail: str) -> str:
|
||||
fw = report.firmware
|
||||
source = " (reported by the box)" if report.led_exact else ""
|
||||
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", "",
|
||||
f"Status LED should be: {report.led_text()}{source}"]
|
||||
if report.led_reason:
|
||||
lines.append(f" from: {report.led_reason}")
|
||||
lines += ["", f"Firmware: {fw.describe()}", f" {SD_WIFI[fw.supports_sd_wifi]}"]
|
||||
if report.box:
|
||||
lines += ["", "Box self-report (KOTA lines):"]
|
||||
lines += [f" {BOX_MARK.get(st, '-')} {topic}: {text}" for topic, text, st in report.box]
|
||||
lines += ["", "Start-up progress (current boot):"]
|
||||
lines += [f" ✔ {label} ({t / 1000:.1f} s)" if t is not None else f" ○ {label}"
|
||||
for _, label, t in report.milestones]
|
||||
lines += ["", "Findings - root causes first:" if report.roots else "Findings: no problems detected in the log"]
|
||||
for rc in report.roots:
|
||||
lines += _finding(rc.finding, "", detail in ("all", "roots"))
|
||||
for c in rc.consequences:
|
||||
lines += _finding(c, " ↳ ", detail == "all")
|
||||
for title, items in (("Seen in earlier boots only", report.earlier),
|
||||
("Resolved during this session", report.resolved)):
|
||||
if items:
|
||||
lines += ["", f"{title} ({len(items)}):"]
|
||||
lines += [f" - [{f.severity.label}] {f.title} - {f.seen_text()}" for f in items]
|
||||
if report.technician:
|
||||
lines += ["", "Technician checks:"]
|
||||
lines += [f" * {t}" for t in report.technician[-12:]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _finding(f, prefix: str, detailed: bool) -> list[str]:
|
||||
ind = " " * len(prefix) + " "
|
||||
out = [f"{prefix}[{f.severity.label}] {f.title}"]
|
||||
if detailed:
|
||||
out.append(f"{ind}What it means: {f.meaning}")
|
||||
if f.actions:
|
||||
out.append(f"{ind}What to do:")
|
||||
out += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)]
|
||||
out += [f"{ind}Error {eid}" + (f": {a}" if a else "") for eid, a in f.error_ids.items()]
|
||||
out.append(f"{ind}{f.seen_text()}")
|
||||
if f.evidence:
|
||||
out.append(f"{ind}Log: {f.evidence[-1]}")
|
||||
return out
|
||||
|
||||
|
||||
def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str:
|
||||
mac = normalize_mac(mac)
|
||||
if not _MAC.match(mac):
|
||||
raise ValueError(f"not a box MAC address: {mac!r}")
|
||||
payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report),
|
||||
"tool": f"v{__version__}"}
|
||||
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
base = base if base.endswith("/") else base + "/"
|
||||
return f"{base}{mac}#kota-report={frag}"
|
||||
|
||||
|
||||
def decode_fragment(url: str) -> dict:
|
||||
"""Inverse of report_url (used by tests and for troubleshooting)."""
|
||||
frag = url.split("#kota-report=", 1)[1]
|
||||
return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8"))
|
||||
+87
-87
@@ -1,87 +1,87 @@
|
||||
"""Quick-start cheat sheet shown on launch.
|
||||
|
||||
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, pattern -> meaning, first action)
|
||||
LED_TABLE = [
|
||||
("#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",
|
||||
"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>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>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
|
||||
"by its compile date.",
|
||||
]
|
||||
|
||||
STEPS = [
|
||||
"Switch the sensorbox <b>power switch ON</b>.",
|
||||
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
|
||||
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
||||
"<b>Connect</b>.",
|
||||
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
||||
"Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
|
||||
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
|
||||
]
|
||||
|
||||
|
||||
def cheatsheet_html() -> str:
|
||||
rows = "".join(
|
||||
f"<tr><td style='padding:6px'><span style='background:{c};"
|
||||
f"border:1px solid #888'> </span> <b>{name}</b></td>"
|
||||
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
|
||||
for c, name, meaning, action in LED_TABLE
|
||||
)
|
||||
steps = "".join(f"<li>{s}</li>" for s in STEPS)
|
||||
hints = "".join(f"<li>{h}</li>" for h in HINTS)
|
||||
return f"""
|
||||
<h2>Sensorbox quick start</h2>
|
||||
<ol>{steps}</ol>
|
||||
<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'>Pattern: meaning</th>
|
||||
<th align='left' style='padding:6px'>First action</th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
<h3>Good to know</h3>
|
||||
<ul>{hints}</ul>
|
||||
"""
|
||||
"""Quick-start cheat sheet shown on launch.
|
||||
|
||||
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, pattern -> meaning, first action)
|
||||
LED_TABLE = [
|
||||
("#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",
|
||||
"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>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>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
|
||||
"by its compile date.",
|
||||
]
|
||||
|
||||
STEPS = [
|
||||
"Switch the sensorbox <b>power switch ON</b>.",
|
||||
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
|
||||
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
||||
"<b>Connect</b>.",
|
||||
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
||||
"Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
|
||||
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
|
||||
]
|
||||
|
||||
|
||||
def cheatsheet_html() -> str:
|
||||
rows = "".join(
|
||||
f"<tr><td style='padding:6px'><span style='background:{c};"
|
||||
f"border:1px solid #888'> </span> <b>{name}</b></td>"
|
||||
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
|
||||
for c, name, meaning, action in LED_TABLE
|
||||
)
|
||||
steps = "".join(f"<li>{s}</li>" for s in STEPS)
|
||||
hints = "".join(f"<li>{h}</li>" for h in HINTS)
|
||||
return f"""
|
||||
<h2>Sensorbox quick start</h2>
|
||||
<ol>{steps}</ol>
|
||||
<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'>Pattern: meaning</th>
|
||||
<th align='left' style='padding:6px'>First action</th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
<h3>Good to know</h3>
|
||||
<ul>{hints}</ul>
|
||||
"""
|
||||
+252
-252
@@ -1,252 +1,252 @@
|
||||
"""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 "")
|
||||
)
|
||||
"""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 "")
|
||||
)
|
||||
+364
-364
@@ -1,364 +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
|
||||
"""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
|
||||
+464
-424
@@ -1,424 +1,464 @@
|
||||
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.manager import SerialManager, State
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
from ..matter.payload import SetupPayload, payload_from_log
|
||||
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
||||
from .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
from .flash_tab import FlashTab
|
||||
from .matter_qr import MatterQrDialog
|
||||
from .guide_panel import GuidePanel
|
||||
from .sd_tab import SdTab
|
||||
|
||||
MAX_LINES = 20000
|
||||
# "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)
|
||||
|
||||
# --- 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
|
||||
|
||||
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)
|
||||
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.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)
|
||||
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)
|
||||
+76
-76
@@ -1,76 +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)
|
||||
"""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,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()
|
||||
@@ -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 ---
|
||||
+1063
-1063
File diff suppressed because it is too large.
Load diff
+1935
-1935
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user