Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb933ff748 | ||
|
|
66cb804102 | ||
|
|
2b6d43eb45 | ||
|
|
834bad2bce | ||
|
|
c41fa101b6 | ||
|
|
e299ef40f0 | ||
|
|
39f0d1718c | ||
|
|
6a62beb6aa | ||
|
|
5bd14e1d5b | ||
|
|
ab420220e1 | ||
|
|
0d21c5c205 | ||
|
|
3fe45fa4d5 | ||
|
|
1ab8af8fe2 | ||
|
|
bfbbd537d5 | ||
|
|
a33736742a | ||
|
|
724c2d62f9 | ||
|
|
43f4c51aa4 | ||
|
|
bfa7edce6f | ||
|
|
dd915b5a74 |
No files matched your search
@@ -1,4 +1,6 @@
|
||||
* text=auto eol=lf
|
||||
*.command text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.bin binary
|
||||
*.png binary
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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).
|
||||
|
||||
**Instrukcja krok po kroku ze zrzutami ekranu: [docs/INSTRUKCJA_TECHNIKA.md](docs/INSTRUKCJA_TECHNIKA.md) (do druku: [PDF](docs/INSTRUKCJA_TECHNIKA.pdf)).**
|
||||
Box podłączaj tak, żeby po drodze była wtyczka USB 2.0 typu A (do komputera albo do huba).
|
||||
|
||||
## 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`).
|
||||
|
||||
## Dane w InfluxDB
|
||||
|
||||
Kontrola **Sensor data in the database (InfluxDB)** (Guided troubleshooting, także ostatni krok Quick check)
|
||||
sprawdza online, czy do InfluxDB trafiają dane z czujników boxa, a nie tylko jego status. Wymaga internetu
|
||||
na laptopie i jednorazowego ustawienia tokenu tylko do odczytu (od zespołu firmware):
|
||||
**Settings > InfluxDB access...**. Tokenu nie zapisujemy w repozytorium.
|
||||
|
||||
## 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,41 @@
|
||||
# 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).
|
||||
- New check **Sensor data in the database (InfluxDB)** (Guided troubleshooting; also the last step of the
|
||||
Quick check on the green path): queries InfluxDB online (read-only) for the box's newest points and tells
|
||||
apart "sensor data arrives", "status arrives but sensor data does not", "offline now" and "never seen".
|
||||
Polls every 30 s for up to 4 minutes. With a stopped RTC in the log it concludes that the sensor readings
|
||||
are lost (they are stamped 1 Jan 2000) - found on box A4:CB:8F:C9:47:3C, which reported only
|
||||
device_status and microphone. The result and the per-measurement times go into the report and the
|
||||
service note.
|
||||
- Settings > **InfluxDB access...**: address, organisation, bucket and a read token (or the
|
||||
`KOTA_INFLUX_TOKEN` environment variable). Defaults: influx.kota-app.com, org `kota`, bucket `sensorbox`.
|
||||
The token is never written to the session log or the service report.
|
||||
- macOS: Connect no longer restarts the box (pyserial dropped DTR before RTS, passing through the
|
||||
USB-Serial-JTAG reset state). Windows unchanged.
|
||||
- **Serial monitor redesigned.** One green **Test box** button: restarts the box, records the start-up and
|
||||
then checks InfluxDB online (data must be newer than the restart), about 4 minutes. A status strip shows
|
||||
what the test is doing and its result. The test checklist (start-up steps + the new row
|
||||
"Sensor data in InfluxDB (online check)") and the findings are the main view; the live log, guided
|
||||
troubleshooting and box details (LED, firmware, self-report) are tabs on the right. Rarely used actions
|
||||
moved to **More**.
|
||||
- Light colour scheme on every system: with macOS dark mode the status colours were unreadable.
|
||||
Larger log font on macOS.
|
||||
- New finding **Box sends an invalid value (-inf/nan) - InfluxDB rejects all its data**: a silent microphone
|
||||
makes the firmware upload `dbfs=-inf,db=-inf`; InfluxDB then refuses the whole upload (HTTP 400), so neither
|
||||
the status nor the sensor data arrive (box A4:CB:8F:C9:47:3C). The InfluxDB check names it as the cause.
|
||||
- The serial port is opened exclusively on macOS/Linux: a second tool window no longer steals half of the
|
||||
log lines; it reports that the port is in use.
|
||||
- Start-up step "Data uploaded to InfluxDB" renamed "Upload accepted by InfluxDB (box log)" - it comes
|
||||
from the box's own log, the online row checks the database.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Technician tool v0.7 - changes
|
||||
|
||||
## Serial reading (firmware 1.0.16+, see firmware-diagnostics-what-changed.md)
|
||||
- `KOTA: <record> key=value` lines are parsed into fields (`core/logline.py`, kind `KOTA`) and interpreted
|
||||
directly by the engine (`diag/engine.py` `_kota_*`, knowledge in `diag/kota.py`) instead of regex rules:
|
||||
boot, i2c, rtc, sd, matter, mic, health, status, led, restart.
|
||||
- Exact status LED (colour + pattern + meaning) from `led` lines; `status` lines give the reason and a
|
||||
status finding that is resolved when the status changes. Cyan LED supported.
|
||||
- Restart cause from `last_restart=` / `restart reason=`; state before a restart from `led previous_boot`.
|
||||
- Error IDs `[E-XX-NN]`: looked up in `diag/error_ids.csv` (copy of the firmware's docs/error_ids.csv);
|
||||
shown with the technician action. Old message rules still match (ID prefix is stripped for matching).
|
||||
- Diagnosis panel: new "Box self-report" section; log view filter "KOTA diagnostics + errors".
|
||||
- Matter QR dialog uses the exact QR/manual code reported by the box.
|
||||
- Older firmware (v1.0) is still diagnosed as before.
|
||||
|
||||
## Flashing
|
||||
- Only `.bin` files in the tool's `firmware/` folder can be flashed (import / developer library removed,
|
||||
no manifest needed). The list shows the compile date read from the firmware itself, newest first.
|
||||
- "Refresh list" and "Open firmware folder" buttons; broken or duplicate files are listed as "Not offered".
|
||||
- Flash verification compares the 8-digit build hash from the `KOTA: boot` line.
|
||||
|
||||
Note: the KOTA lines are tested against lines built from the firmware source; the firmware has not yet
|
||||
been confirmed on hardware.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Technician tool v0.8 - changes
|
||||
|
||||
## Windows + macOS
|
||||
- Dependencies verified to install on macOS arm64 and x86_64 (uv.lock: PySide6 6.11 universal2 wheels,
|
||||
requires macOS 13+); Windows unchanged.
|
||||
- SD card check: macOS `diskutil` reports FAT32 partitions as `DOS_FAT_32` / `Windows_FAT_32`; these are now
|
||||
accepted (previously a correct card was reported as "not FAT32" on a Mac).
|
||||
- Unit tests for the macOS serial-port filter (`/dev/cu.*` only) and FAT32 names.
|
||||
- README.md with launch steps for both systems (incl. the Google Drive executable-bit workaround on macOS);
|
||||
docs/mac_agent_command.txt updated for a v0.8 test run on a Mac.
|
||||
|
||||
## Diagnosis
|
||||
- Harmless Wi-Fi start-up errors are ignored (rule `benign.wifi_start`): `wifi:sta is connecting, return error`,
|
||||
`esp_wifi_connect() failed: ESP_ERR_WIFI_CONN`, `Failed to get configured network when updating network
|
||||
status` (unpaired box).
|
||||
- Brown-out resets are detected (`E BOD: Brownout detector was triggered`, ROM reports RTC_SW_SYS_RST).
|
||||
- Service report note = the Diagnosis panel as text (LED, firmware, box self-report, start-up progress with
|
||||
times, findings with "Seen Nx, first at ... s, last at ... s" and log line), shortened to fit the service
|
||||
form's 5000 characters.
|
||||
|
||||
Not yet run on a real Mac: see docs/mac_agent_command.txt.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Technician tool v0.9 - changes
|
||||
|
||||
- Two files per system for technicians: `update_windows.bat` / `update_macos.command` (update) and
|
||||
`run_windows.bat` / `run_macos.command` (start).
|
||||
- Update: checks Gitea for the newest release tag `vX.Y`, downloads only what changed (git), shows the list of
|
||||
changes, protects files edited by hand (asks first), connects a folder unpacked from a release zip on first
|
||||
run, installs git / uv when missing (asks first) and brings the Python libraries up to date.
|
||||
- One shared Python environment for all versions: `KotaTechnicianTool/venv` (was `venv-0.x` per version).
|
||||
- A developer copy on a branch is left alone by the update script (use `git pull`).
|
||||
@@ -0,0 +1,204 @@
|
||||
# KOTA Sensorbox Technician Tool – instrukcja dla technika
|
||||
|
||||
Program służy do sprawdzenia sensorboxa przez USB, podłączenia go do Wi-Fi bez otwierania obudowy,
|
||||
wgrania firmware i przygotowania karty SD. Działa na Windows 10/11 i macOS 13 lub nowszym.
|
||||
|
||||
> ⚠️ **Między boxem a komputerem musi być po drodze wtyczka USB 2.0 typu A (prostokątna).**
|
||||
> Może to być kabel USB 2.0 A wpięty do komputera albo do huba – hub jest w porządku.
|
||||
> Połączenie wyłącznie przez USB-C (kabel USB-C–USB-C, przejściówka USB-C bez odcinka USB 2.0 A)
|
||||
> często kończy się tym, że box nie jest widoczny w systemie („Unknown USB Device”) albo restartuje się przez
|
||||
> spadki napięcia. Kabel musi przenosić dane – kabel „tylko do ładowania” nie zadziała.
|
||||
|
||||
---
|
||||
|
||||
## Spis treści
|
||||
|
||||
1. [Instalacja (raz na komputer)](#1-instalacja-raz-na-komputer)
|
||||
2. [Aktualizacja i uruchomienie](#2-aktualizacja-i-uruchomienie)
|
||||
3. [Podłączenie boxa](#3-podłączenie-boxa)
|
||||
4. [Diagnoza boxa – zakładka Serial monitor](#4-diagnoza-boxa--zakładka-serial-monitor)
|
||||
5. [Sprawdzenie krok po kroku – Guided troubleshooting](#5-sprawdzenie-krok-po-kroku--guided-troubleshooting)
|
||||
6. [Wi-Fi przez USB – zakładka Wi-Fi (USB)](#6-wi-fi-przez-usb--zakładka-wi-fi-usb)
|
||||
7. [Wgrywanie firmware – zakładka Flash firmware](#7-wgrywanie-firmware--zakładka-flash-firmware)
|
||||
8. [Karta SD – zakładka SD card](#8-karta-sd--zakładka-sd-card)
|
||||
9. [Raport serwisowy](#9-raport-serwisowy)
|
||||
10. [Co oznacza kolor diody](#10-co-oznacza-kolor-diody)
|
||||
11. [Najczęstsze problemy](#11-najczęstsze-problemy)
|
||||
|
||||
---
|
||||
|
||||
## 1. Instalacja (raz na komputer)
|
||||
|
||||
1. Na https://gitea.kota-app.com/kkallas/kota-technician-tool 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`). **Nie** do folderu Google Drive ani OneDrive.
|
||||
3. Uruchom plik aktualizacji dla swojego systemu (patrz rozdział 2). Za pierwszym razem:
|
||||
- zapyta o login do Gitea (hasło lub token) – system go zapamięta,
|
||||
- za Twoją zgodą zainstaluje brakujące narzędzia (Git, `uv`),
|
||||
- pobierze biblioteki (1–2 minuty, potrzebny internet).
|
||||
|
||||
**macOS:** jeśli Finder nie chce otworzyć pliku `.command`, otwórz Terminal, wpisz `bash ` (ze spacją),
|
||||
przeciągnij plik do okna Terminala i naciśnij Enter. Po pierwszej aktualizacji dwuklik zaczyna działać.
|
||||
|
||||
## 2. Aktualizacja i uruchomienie
|
||||
|
||||
W folderze narzędzia potrzebujesz tylko dwóch plików:
|
||||
|
||||
| System | Aktualizacja | Uruchomienie |
|
||||
|---|---|---|
|
||||
| Windows | `update_windows.bat` | `run_windows.bat` |
|
||||
| macOS | `update_macos.command` | `run_macos.command` |
|
||||
|
||||
- **Aktualizacja** – uruchom przed wyjazdem do klienta albo gdy zespół ogłosi nową wersję. Pobiera tylko zmiany
|
||||
i pokazuje ich listę. Wymaga internetu.
|
||||
- **Uruchomienie** – otwiera program. Działa bez internetu.
|
||||
|
||||
Po uruchomieniu otwiera się zakładka **Quick start** ze skróconą instrukcją i tabelą kolorów diody:
|
||||
|
||||

|
||||
|
||||
## 3. Podłączenie boxa
|
||||
|
||||
1. Włącz box włącznikiem z tyłu obudowy (**ON**). Samo zasilanie z laptopa może nie wystarczyć, szczególnie
|
||||
przy słabej baterii.
|
||||
2. Podłącz box tak, żeby **po drodze była wtyczka USB 2.0 typu A** – do portu USB-A komputera albo do huba
|
||||
(patrz ostrzeżenie na górze).
|
||||
3. Na liście portów box widać jako:
|
||||
- Windows: `COMx - Sensorbox (native USB) [MAC]`
|
||||
- macOS: `/dev/cu.usbmodemXXXX - Sensorbox (native USB) [MAC]`
|
||||
|
||||
MAC (np. `14:C1:9F:57:72:48`) to numer identyfikacyjny boxa – zapisz go w raporcie.
|
||||
|
||||
Jeśli boxa nie ma na liście, zobacz [Najczęstsze problemy](#11-najczęstsze-problemy).
|
||||
|
||||
## 4. Diagnoza boxa – zakładka Serial monitor
|
||||
|
||||
1. Wybierz port sensorboxa i kliknij **Connect**.
|
||||
2. Kliknij **Reset & capture** – box uruchomi się ponownie, a program nagra cały start.
|
||||
3. Poczekaj ok. **70 sekund** – co minutę box wysyła raport stanu (health), a program uzupełnia diagnozę.
|
||||
|
||||

|
||||
|
||||
Po lewej jest log z boxa, po prawej panel **Diagnosis** – czytaj go od góry:
|
||||
|
||||
| Element | Co oznacza |
|
||||
|---|---|
|
||||
| **Pasek na górze** | Werdykt: zielony = wszystko działa, żółty/czerwony = główny problem. |
|
||||
| **Status LED should be** | Jaki kolor i wzór powinna mieć dioda. **Porównaj z boxem** – jeśli jest inaczej, zanotuj to. |
|
||||
| **Firmware** | Wersja i build firmware na boxie. |
|
||||
| **Box self-report** | Co box sam o sobie zgłasza: czujniki (I2C), karta SD, zegar, Matter, mikrofon, Wi-Fi, stan co minutę. ✔ = OK, ! = uwaga, ✖ = błąd. |
|
||||
| **Start-up progress** | Kolejne etapy startu z czasem w sekundach. Szare kółko = etap jeszcze nieosiągnięty. |
|
||||
| **Findings** | Znalezione problemy – najpierw przyczyny, pod nimi (↳) ich skutki. Kliknij, by zobaczyć, **co to znaczy** i **co zrobić**. |
|
||||
|
||||
Przydatne przyciski:
|
||||
- **Show: KOTA diagnostics + errors** – pokazuje w logu tylko najważniejsze linie.
|
||||
- **Copy report** – kopiuje diagnozę jako tekst (np. do maila).
|
||||
- **Open logs folder** – pełne logi każdej sesji (do wysłania zespołowi firmware).
|
||||
|
||||
## 5. Sprawdzenie krok po kroku – Guided troubleshooting
|
||||
|
||||
Obok zakładki **Diagnosis** jest **Guided troubleshooting**. Program proponuje sprawdzenia pasujące do
|
||||
znalezionych problemów. Zacznij od **Quick check – start here**:
|
||||
|
||||

|
||||
|
||||
Program zadaje pytania (np. jaki kolor ma dioda) i na bieżąco sprawdza log boxa. Odpowiedzi trafiają do raportu:
|
||||
|
||||

|
||||
|
||||
## 6. Wi-Fi przez USB – zakładka Wi-Fi (USB)
|
||||
|
||||
Pozwala podłączyć box do sieci Wi-Fi **bez otwierania obudowy i wyjmowania karty SD**.
|
||||
Wymaga firmware z komendami USB – jeśli program zgłosi „The box does not answer USB commands”, najpierw wgraj
|
||||
najnowszy firmware (rozdział 7).
|
||||
|
||||
1. Kliknij **Connect to the box** (jeśli box nie jest już połączony w Serial monitor).
|
||||
2. Opcjonalnie **Read current Wi-Fi settings** – pokazuje zapisaną sieć (hasła nigdy nie pokazuje).
|
||||
3. Wpisz **nazwę sieci (SSID)** dokładnie jak na routerze i **hasło** (8–63 znaki).
|
||||
Sieć musi być **2,4 GHz** i mieć hasło.
|
||||
4. Kliknij **Write to the box and connect** i potwierdź.
|
||||
5. Obserwuj listę kroków. Box zapisuje ustawienia, restartuje się i łączy z siecią. Na końcu zobaczysz
|
||||
**„Connected to '…' (signal … dBm)”**:
|
||||
|
||||

|
||||
|
||||
- Siła sygnału: powyżej −70 dBm jest dobrze, poniżej −75 dBm połączenie może się zrywać – przestaw box lub router.
|
||||
- Po zapisaniu Wi-Fi box **nie używa Matter**. Powrót do Matter: **Remove Wi-Fi (back to Matter)**.
|
||||
- Jeśli box nie połączy się w 90 s, sprawdź nazwę sieci, hasło i czy to sieć 2,4 GHz. Ustawienia zostają na
|
||||
boxie, a box sam próbuje dalej.
|
||||
|
||||
## 7. Wgrywanie firmware – zakładka Flash firmware
|
||||
|
||||

|
||||
|
||||
1. **Sensorbox** – wybierz port. Program pokazuje, jaki firmware jest teraz na boxie i czy wybrany plik jest
|
||||
nowszy.
|
||||
2. **Firmware** – wybierz plik. Lista pokazuje **datę kompilacji**, najnowszy jest na górze. Dostępne są tylko
|
||||
pliki z folderu `firmware` narzędzia (aktualizuje je `update_…`).
|
||||
3. **Co wgrać:**
|
||||
- **App only** (zalecane przy naprawach) – wymienia tylko program. Parowanie Matter, rejestracja aktualizacji
|
||||
i ustawienia **zostają**.
|
||||
- **Full image** – tylko dla nowych lub całkowicie wyczyszczonych płytek. **Kasuje parowanie Matter** – box
|
||||
trzeba potem sparować od nowa. Wymaga zaznaczenia potwierdzenia.
|
||||
- **Back up…** – kopia całej pamięci boxa przed wgraniem (ok. 3 minuty). Zalecane przy Full image.
|
||||
4. Kliknij **Start flashing**. Nie odłączaj kabla, dopóki nie pojawi się **„Success”** – program sam uruchomi
|
||||
box ponownie i sprawdzi, czy działa nowy firmware.
|
||||
|
||||
Jeśli wgrywanie nie może się połączyć: inny port/kabel (z wtyczką USB 2.0 A po drodze), zamknij inne programy używające
|
||||
portu. Ostateczność (trzeba otworzyć obudowę): przytrzymaj **BOOT**, naciśnij i puść **RESET**, puść **BOOT**,
|
||||
kliknij Start jeszcze raz.
|
||||
|
||||
## 8. Karta SD – zakładka SD card
|
||||
|
||||
Karta SD jest **wewnątrz obudowy**. Ta zakładka służy do przygotowania nowej karty albo naprawy uszkodzonej.
|
||||
Do zmiany samego Wi-Fi używaj zakładki **Wi-Fi (USB)** – nie trzeba wtedy otwierać obudowy.
|
||||
|
||||

|
||||
|
||||
1. **Settings** – ustawienia wczytują się z pliku od zespołu (INFLUX_URL, INFLUX_TOKEN). Nie zmieniaj ich bez
|
||||
potrzeby. Opcjonalnie możesz wpisać Wi-Fi (sieć i hasło).
|
||||
2. Włóż kartę do czytnika w komputerze. Na liście pojawiają się tylko karty wymienne – dyski komputera nie są
|
||||
pokazywane.
|
||||
3. **Check card** – sprawdza kartę bez zmieniania czegokolwiek.
|
||||
**Write ENV.TXT only** – zapisuje ustawienia, zostawia pomiary na karcie.
|
||||
**Format card and write ENV.TXT** – kasuje całą kartę (FAT32) i zapisuje ustawienia.
|
||||
4. Program sam wysuwa kartę – dopiero wtedy ją wyjmij.
|
||||
|
||||
## 9. Raport serwisowy
|
||||
|
||||
Kliknij **Service report…** w zakładce Serial monitor. Otworzy się strona boxa w systemie KOTA Serwis
|
||||
z wpisaną diagnozą (werdykt, stan boxa, etapy startu, problemy z liczbą wystąpień i czasem). Sprawdź treść,
|
||||
dopisz lokalizację i uwagi, kliknij **Zapisz**.
|
||||
|
||||
Zaznaczenie **Open service report on disconnect** otwiera raport automatycznie po rozłączeniu boxa.
|
||||
|
||||
## 10. Co oznacza kolor diody
|
||||
|
||||
| Kolor | Wzór | Znaczenie | Co zrobić |
|
||||
|---|---|---|---|
|
||||
| Zielony | ciągły | Wszystko OK | Nic |
|
||||
| Cyan (turkusowy) | pulsuje / szybko miga | Nie sparowany w Matter / telefon łączy się | Sparuj w aplikacji Matter albo ustaw Wi-Fi przez USB |
|
||||
| Biały | szybko miga / ciągły / pulsuje | Błąd karty SD lub ENV.TXT / reset fabryczny / tryb konfiguracji Wi-Fi | Sprawdź kartę SD (zakładka SD card) |
|
||||
| Czerwony | 1–6 błysków | Awaria czujnika: 1 BME688, 2 SEN66, 3 AS7341, 4 mikrofon, 5 flicker, 6 test po aktualizacji | Policz błyski, sprawdź przewód tego czujnika |
|
||||
| Czerwony | ciągły | Inny błąd wewnętrzny / brak pamięci | Zapisz log, wyślij zespołowi |
|
||||
| Niebieski | 1 / 2 błyski | Zegar RTC nie odpowiada / zablokowana magistrala I2C | Sprawdź przewód I2C między płytkami |
|
||||
| Fioletowy | ciągły / miga | Brak sieci / serwer odrzuca box (złe ENV.TXT) | Sprawdź Wi-Fi / wgraj poprawne ENV.TXT |
|
||||
| Żółty | miga coraz szybciej | Pobiera aktualizację | Nie wyłączaj, poczekaj |
|
||||
| Pomarańczowy | ciągły | Test po aktualizacji (~2,5 min) | Poczekaj |
|
||||
| Zgaszona | – | Firmware nie działa lub brak zasilania | Sprawdź włącznik i kabel; wgraj firmware |
|
||||
|
||||
Mała dioda obok (heartbeat): miga raz na sekundę = online, podwójnie = offline, nie miga = firmware stoi.
|
||||
|
||||
## 11. Najczęstsze problemy
|
||||
|
||||
| Objaw | Przyczyna | Co zrobić |
|
||||
|---|---|---|
|
||||
| Boxa nie ma na liście portów | Brak wtyczki USB 2.0 A po drodze (samo USB-C), kabel tylko do ładowania, box wyłączony | **Połączenie z wtyczką USB 2.0 typu A** (do komputera lub huba), kabel z transmisją danych, włącznik ON, odłącz i podłącz ponownie. W Windows sprawdź Menedżer urządzeń – „Unknown USB Device” oznacza problem z kablem/portem/zasilaniem. |
|
||||
| Diagnoza: „Power supply voltage drops (brown-out)”, box się restartuje | Za słabe zasilanie | Włącznik boxa ON, inny port USB-A, zasilacz; naładuj baterię |
|
||||
| „The box does not answer USB commands” (Wi-Fi USB) | Stary firmware | Wgraj najnowszy firmware (App only) |
|
||||
| „Clock chip was stopped at start-up” | Pusta bateria zegara RTC | Zegar ustawi się sam po połączeniu z internetem; przy powtórce wymień baterię pastylkową |
|
||||
| „Box is not connected to Wi-Fi (not commissioned in Matter)” | Box nie jest sparowany | Sparuj w Matter (przycisk **Matter pairing QR code…**) albo ustaw Wi-Fi przez USB |
|
||||
| Wgrywanie nie może połączyć się z boxem | Port zajęty lub zły kabel | Zamknij inne programy, inny kabel/port; ostatecznie BOOT + RESET (rozdział 7) |
|
||||
|
||||
Jeśli problem się powtarza: **Open logs folder** → wyślij plik sesji zespołowi firmware razem z MAC boxa.
|
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -1,9 +1,10 @@
|
||||
You are testing the KOTA sensorbox technician tool v0.5 on macOS. The current folder is
|
||||
004-technician_tool/v0.5 (a Python/PySide6 desktop app; it has only been tested on Windows so far).
|
||||
You are testing the KOTA sensorbox technician tool v0.8 on macOS. The current folder is
|
||||
004-technician_tool/v0.8 (a Python/PySide6 desktop app; tested on Windows 11, macOS paths are covered by
|
||||
unit tests but not yet run on a real Mac).
|
||||
|
||||
Rules:
|
||||
- Python only via uv or conda/mamba. Never use bare/system python or pip.
|
||||
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin.
|
||||
- Do not build or modify firmware. Flash only files from the tool's firmware/ folder.
|
||||
- Do not push anything to Git or GitLab. This is a local demo.
|
||||
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
|
||||
Before formatting, show me the exact disk (identifier, name, size, current files).
|
||||
@@ -11,20 +12,24 @@ Rules:
|
||||
|
||||
Tests, in order. Report pass/fail with evidence for each:
|
||||
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
|
||||
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q`
|
||||
Also check whether a double-click in Finder works (Google Drive may drop the executable bit).
|
||||
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8" uv run --frozen pytest -q`
|
||||
(expect 172 passed).
|
||||
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
|
||||
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the
|
||||
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the
|
||||
reset and whether the tool reconnects.
|
||||
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
|
||||
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself,
|
||||
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output.
|
||||
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed
|
||||
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT".
|
||||
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical
|
||||
to the template, and eject the card.
|
||||
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine
|
||||
if the page itself cannot prefill yet).
|
||||
entry with a MAC (no /dev/tty.* duplicate). Connect, press "Reset & capture": the log starts at
|
||||
"ESP-ROM:esp32s3" and the Diagnosis panel shows firmware v1.0.16 (build 4f10989e), the "Box self-report"
|
||||
section and the LED with its pattern. Check whether the port disappears after the reset and whether the
|
||||
tool reconnects. Wait 2 minutes: a "KOTA: health" line appears every 60 s.
|
||||
4. Show filter "KOTA diagnostics + errors" shows only KOTA lines and errors.
|
||||
5. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
|
||||
6. Flash (App only, the newest file in firmware/, chosen by its compile date): the tool must enter flashing mode
|
||||
by itself, write, restart the box and report "Success ... build 4f10989e". Capture the esptool output.
|
||||
7. SD card tab: the card reader and card are listed (internal disks must NOT be listed). "Check card" must NOT
|
||||
report a wrong file system for a FAT32 card. After my OK, "Format card and write ENV.TXT" must use
|
||||
`diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT and eject the card.
|
||||
8. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=...; the note in
|
||||
the form must match the Diagnosis panel (self-report, start-up progress with times, findings with
|
||||
"Seen Nx, first at ... s").
|
||||
|
||||
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
|
||||
suggested fixes (file:line).
|
||||
@@ -1,3 +1,3 @@
|
||||
"""KOTA sensorbox technician tool."""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.9.0"
|
||||
@@ -1,13 +1,42 @@
|
||||
import sys
|
||||
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .ui.main_window import MainWindow
|
||||
|
||||
|
||||
def light_palette() -> QPalette:
|
||||
"""The same light look on every system. All status colours in the tool are
|
||||
chosen for a light background; with macOS dark mode they were unreadable."""
|
||||
p = QPalette()
|
||||
roles = {
|
||||
QPalette.ColorRole.Window: "#f4f5f7",
|
||||
QPalette.ColorRole.WindowText: "#1d1d1f",
|
||||
QPalette.ColorRole.Base: "#ffffff",
|
||||
QPalette.ColorRole.AlternateBase: "#f0f2f5",
|
||||
QPalette.ColorRole.Text: "#1d1d1f",
|
||||
QPalette.ColorRole.Button: "#e9ebef",
|
||||
QPalette.ColorRole.ButtonText: "#1d1d1f",
|
||||
QPalette.ColorRole.ToolTipBase: "#ffffe8",
|
||||
QPalette.ColorRole.ToolTipText: "#1d1d1f",
|
||||
QPalette.ColorRole.PlaceholderText: "#8a8a8a",
|
||||
QPalette.ColorRole.Highlight: "#2e6da4",
|
||||
QPalette.ColorRole.HighlightedText: "#ffffff",
|
||||
QPalette.ColorRole.Link: "#1f5fa8",
|
||||
QPalette.ColorRole.BrightText: "#ffffff",
|
||||
}
|
||||
for role, colour in roles.items():
|
||||
p.setColor(role, QColor(colour))
|
||||
for role in (QPalette.ColorRole.WindowText, QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText):
|
||||
p.setColor(QPalette.ColorGroup.Disabled, role, QColor("#9a9a9a"))
|
||||
return p
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle("Fusion")
|
||||
app.setPalette(light_palette())
|
||||
win = MainWindow()
|
||||
win.show()
|
||||
return app.exec()
|
||||
|
||||
@@ -4,6 +4,12 @@ ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally
|
||||
wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset
|
||||
reason) has no level prefix and is classified separately, since a boot
|
||||
banner is the only reliable reboot marker.
|
||||
|
||||
Firmware 1.0.16+ adds structured diagnostic lines logged under the "KOTA"
|
||||
tag ("I (812) KOTA: boot fw=1.0.16 build=4f10989e ... last_restart=none")
|
||||
and prefixes every ESP_LOGE message with a stable error ID ("[E-SD-03] ").
|
||||
Both are parsed here so the diagnostics engine reads fields directly
|
||||
instead of matching message text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,6 +27,9 @@ ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
|
||||
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
|
||||
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
|
||||
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
|
||||
# "[E-SD-03] Failed to initialize the card ..." (firmware error_ids.csv).
|
||||
ERROR_ID_RE = re.compile(r"^\[(E-[A-Z0-9]+-\d{2,})\] ?")
|
||||
KOTA_TAG = "KOTA"
|
||||
PANIC_RE = re.compile(
|
||||
r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|"
|
||||
r"^Backtrace:|Rebooting\.\.\.$"
|
||||
@@ -32,6 +41,7 @@ class LineKind(str, Enum):
|
||||
BOOT = "boot" # ROM boot banner (start of a new boot)
|
||||
RESET = "reset" # ROM reset-reason line
|
||||
PANIC = "panic" # crash / abort output
|
||||
KOTA = "kota" # structured "KOTA: <record> key=value ..." line
|
||||
RAW = "raw" # anything else (task list rows, line protocol, ...)
|
||||
HOST = "host" # event generated by this tool (connect, reset, ...)
|
||||
|
||||
@@ -50,6 +60,44 @@ class LogLine:
|
||||
tag: str | None = None
|
||||
message: str | None = None
|
||||
reset_reason: str | None = None
|
||||
error_id: str | None = None # "E-SD-03" when the message carries one
|
||||
record: str | None = None # KOTA lines: "boot", "i2c", "health", ...
|
||||
fields: dict[str, str] = field(default_factory=dict) # KOTA key=value pairs
|
||||
words: tuple[str, ...] = () # KOTA bare words ("verify", "OK", "->", ...)
|
||||
|
||||
@property
|
||||
def match_text(self) -> str:
|
||||
"""Text for message rules: the error-ID prefix removed, so rules
|
||||
written for older firmware keep matching."""
|
||||
if self.error_id and self.message is not None and self.tag:
|
||||
return self.text.replace(f"[{self.error_id}] ", "", 1)
|
||||
return self.text
|
||||
|
||||
|
||||
def parse_kota(body: str) -> tuple[str, dict[str, str], tuple[str, ...]]:
|
||||
"""'rtc verify running=1 delta=2s ok=1' -> ('rtc', {...}, ('verify',)).
|
||||
|
||||
Values never contain spaces. The mic line has a parenthesised aside
|
||||
("(GPIO3=1, expected 1)"): brackets and commas are stripped from keys
|
||||
and values, and the aside's words are kept as bare words.
|
||||
"""
|
||||
tokens = body.split()
|
||||
if not tokens:
|
||||
return "", {}, ()
|
||||
fields: dict[str, str] = {}
|
||||
words: list[str] = []
|
||||
for tok in tokens[1:]:
|
||||
key, eq, value = tok.partition("=")
|
||||
if eq and key.strip("(,"):
|
||||
key = key.lstrip("(")
|
||||
# "0x68=DS1307(missing)" keeps its bracket; "(GPIO3=1," loses it.
|
||||
value = value.rstrip(",")
|
||||
if tok.startswith("(") or value.endswith(")") and "(" not in value:
|
||||
value = value.rstrip(")")
|
||||
fields[key] = value
|
||||
else:
|
||||
words.append(tok.strip("(),"))
|
||||
return tokens[0], fields, tuple(w for w in words if w)
|
||||
|
||||
|
||||
def clean(raw: str) -> str:
|
||||
@@ -61,7 +109,15 @@ def parse_line(raw: str, host_time: float | None = None) -> LogLine:
|
||||
t = time.time() if host_time is None else host_time
|
||||
if m := ESP_LOG_RE.match(text):
|
||||
level, ms, tag, msg = m.groups()
|
||||
return LogLine(text, LineKind.LOG, t, level, int(ms), tag.strip(), msg)
|
||||
tag = tag.strip()
|
||||
if tag == KOTA_TAG:
|
||||
record, fields, words = parse_kota(msg)
|
||||
return LogLine(text, LineKind.KOTA, t, level, int(ms), tag, msg,
|
||||
record=record, fields=fields, words=words)
|
||||
eid = None
|
||||
if em := ERROR_ID_RE.match(msg):
|
||||
eid, msg = em.group(1), msg[em.end():]
|
||||
return LogLine(text, LineKind.LOG, t, level, int(ms), tag, msg, error_id=eid)
|
||||
if BOOT_BANNER_RE.match(text):
|
||||
return LogLine(text, LineKind.BOOT, t)
|
||||
if m := RESET_REASON_RE.match(text):
|
||||
|
||||
@@ -6,7 +6,11 @@ line cannot express:
|
||||
was missed) and classification of why the previous boot ended;
|
||||
* the status-LED model (Status::set_status / set_status_conditional);
|
||||
* crash capture (panic / ESP_ERROR_CHECK output);
|
||||
* root-cause grouping via `explained_by` chains.
|
||||
* root-cause grouping via `explained_by` chains;
|
||||
* the structured "KOTA:" lines of firmware 1.0.16+ (diag/kota.py): exact
|
||||
status/LED, restart reasons, I2C/SD/RTC/Matter/mic self-report and the
|
||||
60 s health line, plus error IDs ("[E-SD-03]") looked up in
|
||||
error_ids.csv. Older firmware is still diagnosed from message text.
|
||||
|
||||
Findings are session-wide (a reboot loop must keep its cause visible);
|
||||
milestones and the LED are per boot.
|
||||
@@ -22,6 +26,7 @@ from enum import IntEnum
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.logline import LineKind, LogLine
|
||||
from . import kota
|
||||
from .firmware import FirmwareInfo
|
||||
|
||||
RULES_PATH = Path(__file__).with_name("rules.toml")
|
||||
@@ -48,6 +53,7 @@ LED = {
|
||||
"PURPLE": ("Purple", "#8e44ad"),
|
||||
"RED": ("Red", "#d64541"),
|
||||
"BLUE": ("Blue", "#2f6fd6"),
|
||||
"CYAN": ("Cyan", "#1fc8d8"),
|
||||
"YELLOW": ("Yellow", "#e6c229"),
|
||||
"ORANGE": ("Orange", "#f08c1a"),
|
||||
"WHITE": ("White", "#f4f4f4"),
|
||||
@@ -62,10 +68,12 @@ MILESTONES = [
|
||||
("matter_started", "Matter started", True),
|
||||
("matter_commissioned", "Paired in Matter", True),
|
||||
("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True),
|
||||
("i2c_ok", "All sensors answer on the I2C bus", True),
|
||||
("sensors_ready", "All sensors started", False),
|
||||
("wifi_connected", "Connected to Wi-Fi", False),
|
||||
("time_synced", "Clock set from the Internet", False),
|
||||
("data_uploaded", "Data uploaded to InfluxDB", False),
|
||||
("clock_verified", "Clock keeps time (RTC verified)", True),
|
||||
("data_uploaded", "Upload accepted by InfluxDB (box log)", False),
|
||||
("ota_registered", "Registered for firmware updates", False),
|
||||
("ota_checked", "Checked for firmware updates", False),
|
||||
("ota_update_started", "Firmware update started", True),
|
||||
@@ -77,11 +85,15 @@ MILESTONES = [
|
||||
_INIT_FAILURES = [
|
||||
"rtc.init_failed", "sensor.init_failed", "sd.card_fail", "sd.mount_fail",
|
||||
"sd.env_open_fail", "influx.env_missing", "influx.init_fail",
|
||||
"kota.i2c_stuck", "kota.sensor_missing", "kota.rtc_read_failed", "kota.sd_unmounted",
|
||||
]
|
||||
_NETWORK_ROOTS = [
|
||||
"influx.buffer_fail", "ota.check_failed", "net.dns_fail",
|
||||
"matter.not_commissioned", "wifi.disconnected",
|
||||
]
|
||||
# Printed by the brown-out handler without a timestamp ("E BOD: ..."); on the
|
||||
# ESP32-S3 the ROM then reports RTC_SW_SYS_RST, not a brown-out reset.
|
||||
_BROWNOUT = "Brownout detector was triggered"
|
||||
_RESET_BY_TOOL_WINDOW_S = 5.0
|
||||
_SHORT_BOOT_MS = 60_000
|
||||
|
||||
@@ -98,7 +110,7 @@ def _fmt(s: str, groups: dict[str, str]) -> str:
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
id: str
|
||||
pattern: re.Pattern[str]
|
||||
pattern: re.Pattern[str] | None # None: raised from KOTA: fields
|
||||
severity: Severity
|
||||
title: str = ""
|
||||
meaning: str = ""
|
||||
@@ -119,7 +131,7 @@ def load_rules(path: Path = RULES_PATH) -> list[Rule]:
|
||||
for r in data["rule"]:
|
||||
rules.append(Rule(
|
||||
id=r["id"],
|
||||
pattern=re.compile(r["pattern"]),
|
||||
pattern=re.compile(r["pattern"]) if "pattern" in r else None,
|
||||
severity=Severity[r["severity"].upper()],
|
||||
title=r.get("title", ""),
|
||||
meaning=r.get("meaning", ""),
|
||||
@@ -145,7 +157,8 @@ def _validate(rules: list[Rule]) -> None:
|
||||
known = set(ids)
|
||||
for r in rules:
|
||||
for ref in (*r.explained_by, *r.clears):
|
||||
if ref not in known:
|
||||
# status.* / reboot.* findings are created by the engine itself.
|
||||
if ref not in known and not ref.startswith(("status.", "reboot.")):
|
||||
raise ValueError(f"{r.id}: unknown rule id {ref!r}")
|
||||
for colour in (r.led, r.led_from):
|
||||
if colour and colour not in LED:
|
||||
@@ -172,11 +185,25 @@ class Finding:
|
||||
resolved: bool = False
|
||||
resolved_by: str | None = None
|
||||
evidence: deque[str] = field(default_factory=lambda: deque(maxlen=3))
|
||||
error_ids: dict[str, str] = field(default_factory=dict) # "E-SD-03" -> technician action
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.count >= self.min_count and not self.resolved
|
||||
|
||||
def seen_text(self) -> str:
|
||||
"""'Seen 3x, first at 2.9 s after start, last at 61.2 s' (panel and service note)."""
|
||||
s = f"Seen {self.count}x"
|
||||
if self.first_uptime_ms is not None:
|
||||
first = f"{self.first_uptime_ms / 1000:.1f}"
|
||||
s += f", first at {first} s after start"
|
||||
last = f"{self.last_uptime_ms / 1000:.1f}" if self.last_uptime_ms is not None else first
|
||||
if self.count > 1 and last != first:
|
||||
s += f", last at {last} s"
|
||||
if self.resolved and self.resolved_by:
|
||||
s += f" - resolved ({self.resolved_by})"
|
||||
return s
|
||||
|
||||
|
||||
@dataclass
|
||||
class BootRecord:
|
||||
@@ -189,8 +216,15 @@ class BootRecord:
|
||||
saw_log: bool = False
|
||||
led_at_end: str | None = None
|
||||
panic_lines: list[str] = field(default_factory=list)
|
||||
brownout: bool = False # "E BOD: Brownout detector was triggered" before the restart
|
||||
recent_errors: deque[str] = field(default_factory=lambda: deque(maxlen=5))
|
||||
intentional_restart: bool = False # factory reset / setup-mode toggle
|
||||
# KOTA: lines (firmware 1.0.16+)
|
||||
kota: dict[str, LogLine] = field(default_factory=dict) # last line per record
|
||||
last_restart: str | None = None # "boot ... last_restart=" (why the previous boot ended)
|
||||
restart_reason: str | None = None # "restart reason=" printed just before this boot ended
|
||||
previous_status: tuple[str, int] | None = None # "led previous_boot status= code="
|
||||
mode: str | None = None # matter | wifi | provisioning
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -218,14 +252,27 @@ class Report:
|
||||
lines: int
|
||||
mid_run: bool # no boot seen yet
|
||||
technician: list[str] = field(default_factory=list) # guided-check answers
|
||||
led_pattern: str = "" # "2 pulses", "solid", ... (exact when from a KOTA led line)
|
||||
led_meaning: str = ""
|
||||
led_exact: bool = False # reported by the box itself
|
||||
box: list[tuple[str, str, str]] = field(default_factory=list) # (topic, text, ok|warn|bad|info)
|
||||
matter_qr: str | None = None
|
||||
matter_manual: str | None = None
|
||||
|
||||
def led_text(self) -> str:
|
||||
t = LED[self.led][0] + (f", {self.led_pattern}" if self.led_pattern else "")
|
||||
return t + (f" = {self.led_meaning}" if self.led_meaning else "")
|
||||
|
||||
def to_text(self) -> str:
|
||||
out = [f"VERDICT: {self.verdict}",
|
||||
f"Expected status LED: {LED[self.led][0]}"
|
||||
f"Expected status LED: {self.led_text()}"
|
||||
+ (f" (last change: {self.led_reason})" if self.led_reason else ""),
|
||||
f"Firmware: {self.firmware.describe()}",
|
||||
f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}",
|
||||
"", "Milestones (current boot):"]
|
||||
f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}"]
|
||||
if self.box:
|
||||
out += ["", "Box self-report (KOTA lines):"]
|
||||
out += [f" {topic}: {text}" for topic, text, _ in self.box]
|
||||
out += ["", "Milestones (current boot):"]
|
||||
for _, label, t in self.milestones:
|
||||
out.append(f" [{'x' if t is not None else ' '}] {label}"
|
||||
+ (f" @ {t / 1000:.1f} s" if t is not None else ""))
|
||||
@@ -256,6 +303,8 @@ class Report:
|
||||
def _finding_text(f: Finding, ind: str) -> list[str]:
|
||||
lines = [f"{ind}- [{f.severity.label}] {f.title} (x{f.count})", f"{ind} {f.meaning}"]
|
||||
lines += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)]
|
||||
for eid, action in f.error_ids.items():
|
||||
lines.append(f"{ind} error {eid}" + (f": {action}" if action else ""))
|
||||
if f.evidence:
|
||||
lines.append(f"{ind} log: {f.evidence[-1]}")
|
||||
return lines
|
||||
@@ -265,6 +314,8 @@ class DiagnosticsEngine:
|
||||
def __init__(self, rules: list[Rule] | None = None):
|
||||
self.rules = rules if rules is not None else load_rules()
|
||||
self._rule_by_id = {r.id: r for r in self.rules}
|
||||
self._pattern_rules = [r for r in self.rules if r.pattern is not None]
|
||||
self.error_ids = kota.load_error_ids()
|
||||
self.reset()
|
||||
|
||||
# ---------------------------------------------------------------- API
|
||||
@@ -281,6 +332,17 @@ class DiagnosticsEngine:
|
||||
self.technician: list[str] = []
|
||||
self.mac: str | None = None # from "ID: Device MAC address: ..." at boot
|
||||
self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line
|
||||
self.matter_qr: str | None = None # "KOTA: matter ... qr=MT:..."
|
||||
self.matter_manual: str | None = None
|
||||
self.kota_seen = False # firmware prints KOTA: lines
|
||||
self.console_api: int | None = None # "cmd ping ... api=N": box accepts USB commands
|
||||
self.wifi_config: dict[str, str] | None = None # last "cmd wifi_get" reply (never a password)
|
||||
self._reset_led_state()
|
||||
|
||||
def _reset_led_state(self) -> None:
|
||||
self.led_pattern = ""
|
||||
self.led_exact = False # a KOTA led line was seen this boot
|
||||
self.status: tuple[str, int] | None = None
|
||||
|
||||
@property
|
||||
def boot(self) -> BootRecord:
|
||||
@@ -293,6 +355,8 @@ class DiagnosticsEngine:
|
||||
if "reset issued by tool" in text:
|
||||
self._tool_reset_at = line.host_time
|
||||
return
|
||||
if _BROWNOUT in text:
|
||||
self.boot.brownout = True
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._new_boot(line.host_time, explicit=True)
|
||||
self.milestones["rom_boot"] = 0
|
||||
@@ -302,7 +366,7 @@ class DiagnosticsEngine:
|
||||
if line.reset_reason and "POWERON" in line.reset_reason:
|
||||
self.led, self.led_reason = "OFF", "power-on - firmware not started yet"
|
||||
return
|
||||
if line.kind == LineKind.LOG and line.uptime_ms is not None:
|
||||
if line.kind in (LineKind.LOG, LineKind.KOTA) and line.uptime_ms is not None:
|
||||
b = self.boot
|
||||
# Uptime jumped back to a fresh-boot value: the ROM banner was
|
||||
# missed (port reconnect, capture started late).
|
||||
@@ -319,14 +383,20 @@ class DiagnosticsEngine:
|
||||
self._panic_tail -= 1
|
||||
self._crash_finding().evidence.append(text)
|
||||
|
||||
if line.kind == LineKind.KOTA:
|
||||
self._on_kota(line)
|
||||
return
|
||||
|
||||
self.firmware.observe(text)
|
||||
if m := _MAC_LINE.search(text):
|
||||
self.mac = m.group(1).upper()
|
||||
if "Advertise commission parameter" in text:
|
||||
self.matter_advert = text
|
||||
matched = False
|
||||
for rule in self.rules:
|
||||
m = rule.pattern.search(text)
|
||||
hit: list[Finding] = []
|
||||
rule_text = line.match_text
|
||||
for rule in self._pattern_rules:
|
||||
m = rule.pattern.search(rule_text)
|
||||
if not m:
|
||||
continue
|
||||
matched = True
|
||||
@@ -335,7 +405,8 @@ class DiagnosticsEngine:
|
||||
groups = {k: v for k, v in m.groupdict().items() if v is not None}
|
||||
if rule.milestone and rule.milestone not in self.milestones:
|
||||
self.milestones[rule.milestone] = line.uptime_ms or 0
|
||||
if rule.led:
|
||||
# The box's own "KOTA: led" lines are exact; message rules only guess.
|
||||
if rule.led and not self.led_exact:
|
||||
self._set_led(rule.led, rule.led_from, text)
|
||||
for cid in rule.clears:
|
||||
for f in self.findings.values():
|
||||
@@ -345,12 +416,18 @@ class DiagnosticsEngine:
|
||||
if rule.id in ("sys.factory_reset",) or "Short press:" in text:
|
||||
self.boot.intentional_restart = True
|
||||
if rule.severity != Severity.IGNORE:
|
||||
self._hit(rule, groups, line)
|
||||
hit.append(self._hit(rule, groups, line))
|
||||
|
||||
self._stateful(line)
|
||||
if line.error_id:
|
||||
info = self.error_ids.get(line.error_id)
|
||||
for f in hit:
|
||||
f.error_ids[line.error_id] = info.action if info else ""
|
||||
if not matched:
|
||||
self._hit_error_id(line, info)
|
||||
if line.level == "E":
|
||||
self.boot.recent_errors.append(text)
|
||||
if not matched and line.tag:
|
||||
if not matched and line.tag and not line.error_id:
|
||||
self._hit_other(line)
|
||||
|
||||
def report(self) -> Report:
|
||||
@@ -364,7 +441,8 @@ class DiagnosticsEngine:
|
||||
# Worst consequence first, then the root's own severity, then order seen.
|
||||
roots.sort(key=lambda rc: (-rc.severity, -rc.finding.severity, rc.finding.first_uptime_ms or 0))
|
||||
|
||||
mid_run = not any(b.explicit for b in self.boots) and self.boot.index == 0
|
||||
mid_run = (not any(b.explicit or "i2c" in b.kota or "boot" in b.kota for b in self.boots)
|
||||
and self.boot.index == 0)
|
||||
if self.lines == 0:
|
||||
verdict, vsev = "Waiting for data from the sensorbox...", None
|
||||
elif roots and roots[0].severity >= Severity.WARNING:
|
||||
@@ -382,8 +460,12 @@ class DiagnosticsEngine:
|
||||
if not optional or mid in self.milestones]
|
||||
# Reached milestones in the order they happened, then the pending ones.
|
||||
ms.sort(key=lambda m: (m[2] is None, m[2] or 0))
|
||||
pattern = self.led_pattern
|
||||
return Report(verdict, vsev, self.led, self.led_reason, self.firmware, ms, roots,
|
||||
earlier, resolved, list(self.boots), self.lines, mid_run, list(self.technician))
|
||||
earlier, resolved, list(self.boots), self.lines, mid_run, list(self.technician),
|
||||
led_pattern=pattern, led_meaning=kota.led_meaning(self.led, pattern) if pattern else "",
|
||||
led_exact=self.led_exact, box=self._box_summary(),
|
||||
matter_qr=self.matter_qr, matter_manual=self.matter_manual)
|
||||
|
||||
def add_confirmed(self, key: str, severity: Severity, title: str, meaning: str,
|
||||
actions: tuple[str, ...]) -> Finding:
|
||||
@@ -418,6 +500,7 @@ class DiagnosticsEngine:
|
||||
self.led_reason = "restarting - LED keeps its last colour until the firmware starts"
|
||||
self.firmware = FirmwareInfo()
|
||||
self._panic_tail = 0
|
||||
self._reset_led_state()
|
||||
|
||||
def _set_led(self, colour: str, led_from: str | None, text: str) -> None:
|
||||
if led_from and self.led != led_from:
|
||||
@@ -435,6 +518,7 @@ class DiagnosticsEngine:
|
||||
key, rule.id, rule.severity, _fmt(rule.title, groups), _fmt(rule.meaning, groups),
|
||||
tuple(_fmt(a, groups) for a in rule.actions), rule.explained_by, rule.min_count)
|
||||
self._touch(f, line)
|
||||
return f
|
||||
|
||||
def _touch(self, f: Finding, line: LogLine) -> None:
|
||||
f.count += 1
|
||||
@@ -467,7 +551,7 @@ class DiagnosticsEngine:
|
||||
"sys.crash", Severity.CRITICAL, "Firmware stopped with an error and restarts",
|
||||
"The firmware hit a fatal error. The error printed just before it is the real cause.",
|
||||
("See the grouped cause; otherwise save the session log for the firmware team.",),
|
||||
tuple(_INIT_FAILURES))
|
||||
tuple(_INIT_FAILURES) + ("reboot.brownout",))
|
||||
|
||||
def _on_panic(self, line: LogLine) -> None:
|
||||
b = self.boot
|
||||
@@ -493,35 +577,381 @@ class DiagnosticsEngine:
|
||||
("Replace the RTC module (inside the case).",))
|
||||
self._touch(f, line)
|
||||
|
||||
# ------------------------------------------------------- KOTA: lines
|
||||
def _on_kota(self, line: LogLine) -> None:
|
||||
self.kota_seen = True
|
||||
rec, f = line.record or "", line.fields
|
||||
# A second boot line in one boot: the ROM banner and the uptime drop
|
||||
# were both missed (e.g. capture started with a gap).
|
||||
if rec == "boot" and "boot" in self.boot.kota:
|
||||
self._new_boot(line.host_time, explicit=False)
|
||||
key = f"{rec}.verify" if rec == "rtc" and "verify" in line.words else rec
|
||||
self.boot.kota[key] = line
|
||||
handler = getattr(self, f"_kota_{rec}", None)
|
||||
if handler is not None:
|
||||
handler(line, f)
|
||||
|
||||
def _raise(self, rule_id: str, line: LogLine, groups: dict[str, str] | None = None) -> Finding:
|
||||
"""Raise a rules.toml finding from parsed KOTA fields (no pattern matching)."""
|
||||
return self._hit(self._rule_by_id[rule_id], groups or {}, line)
|
||||
|
||||
def _resolve(self, rule_id: str, by: str, key: str | None = None) -> None:
|
||||
for f in self.findings.values():
|
||||
if f.rule_id == rule_id and (key is None or f.key == key) and not f.resolved:
|
||||
f.resolved, f.resolved_by = True, by
|
||||
|
||||
def _milestone(self, mid: str, line: LogLine) -> None:
|
||||
if mid not in self.milestones:
|
||||
self.milestones[mid] = line.uptime_ms or 0
|
||||
|
||||
def _kota_boot(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
b = self.boot
|
||||
self.firmware.observe_kota(f)
|
||||
mac = f.get("mac", "")
|
||||
if re.fullmatch(r"[0-9A-Fa-f]{12}", mac):
|
||||
self.mac = ":".join(mac[i:i + 2] for i in range(0, 12, 2)).upper()
|
||||
b.reset_reason = b.reset_reason or f.get("reset")
|
||||
b.last_restart = f.get("last_restart")
|
||||
b.mode = f.get("mode")
|
||||
self._milestone("app_started", line)
|
||||
|
||||
def _kota_restart(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
reason = f.get("reason", "")
|
||||
self.boot.restart_reason = reason
|
||||
if reason in kota.INTENTIONAL_RESTARTS:
|
||||
self.boot.intentional_restart = True
|
||||
|
||||
def _kota_i2c(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if f.get("bus") == "stuck":
|
||||
self._raise("kota.i2c_stuck", line, {"sda": f.get("SDA", "?"), "scl": f.get("SCL", "?")})
|
||||
return
|
||||
self._resolve("kota.i2c_stuck", "I2C bus free again")
|
||||
missing = False
|
||||
for addr, value in f.items():
|
||||
if not addr.startswith("0x") or value == "?":
|
||||
continue
|
||||
name = value.removesuffix("(missing)")
|
||||
if value.endswith("(missing)"):
|
||||
missing = True
|
||||
self._raise("kota.sensor_missing", line, {"sensor": name, "addr": addr})
|
||||
else:
|
||||
self._resolve("kota.sensor_missing", f"{name} found on I2C", f"kota.sensor_missing:{name}")
|
||||
if not missing:
|
||||
self._milestone("i2c_ok", line)
|
||||
|
||||
def _kota_rtc(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if "verify" in line.words:
|
||||
if f.get("ok") == "1":
|
||||
self._milestone("clock_verified", line)
|
||||
self._resolve("kota.rtc_not_ticking", "clock verified")
|
||||
else:
|
||||
self._raise("kota.rtc_not_ticking", line,
|
||||
{"running": f.get("running", "?"), "delta": f.get("delta", "no reading")})
|
||||
elif "read_failed" in line.words:
|
||||
self._raise("kota.rtc_read_failed", line, {"err": f.get("err", "?")})
|
||||
elif f.get("running") == "0":
|
||||
self._raise("kota.rtc_stopped", line, {"time": f.get("time", "?")})
|
||||
elif f.get("running") == "1":
|
||||
self._resolve("kota.rtc_read_failed", "clock answers")
|
||||
self._resolve("kota.rtc_stopped", "clock running")
|
||||
|
||||
def _kota_sd(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if f.get("mounted") == "0":
|
||||
self._raise("kota.sd_unmounted", line, {"err": f.get("err", "card missing or unreadable")})
|
||||
return
|
||||
self._milestone("sd_mounted", line)
|
||||
self._resolve("kota.sd_unmounted", "SD card mounted")
|
||||
keys = {k for k in f.get("env_keys", "").split(",") if k and k != "-"}
|
||||
missing = [k for k in ("INFLUX_URL", "INFLUX_TOKEN") if k not in keys]
|
||||
for k in missing:
|
||||
self._raise("influx.env_missing", line, {"key": k})
|
||||
if not missing:
|
||||
self._milestone("env_loaded", line)
|
||||
if f.get("fs") and f["fs"] != "FAT32":
|
||||
self._raise("kota.sd_not_fat32", line, {"fs": f["fs"]})
|
||||
free = kota.parse_size_gb(f.get("free", ""))
|
||||
if free is not None and free < 0.1:
|
||||
self._raise("kota.sd_full", line, {"free": f["free"]})
|
||||
lines = f.get("buffer_lines", "0")
|
||||
if lines.isdigit() and int(lines) > 500:
|
||||
self._raise("kota.buffer_backlog", line, {"lines": lines})
|
||||
|
||||
def _kota_matter(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if f.get("qr", "-").startswith("MT:"):
|
||||
self.matter_qr = f["qr"]
|
||||
if f.get("manual", "-") != "-":
|
||||
self.matter_manual = f["manual"]
|
||||
fabrics = f.get("fabrics", "")
|
||||
if fabrics == "0":
|
||||
self._raise("matter.not_commissioned", line)
|
||||
elif fabrics.isdigit():
|
||||
self._milestone("matter_commissioned", line)
|
||||
self._resolve("matter.not_commissioned", "paired in Matter")
|
||||
|
||||
def _kota_mic(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if f.get("enabled") == "0":
|
||||
self._raise("mic.disabled", line)
|
||||
elif f.get("enabled") == "1":
|
||||
self._resolve("mic.disabled", "microphone switched on")
|
||||
|
||||
def _kota_status(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
w = line.words
|
||||
new = w[w.index("->") + 1] if "->" in w and w.index("->") + 1 < len(w) else (w[-1] if w else "")
|
||||
code = int(f["code"]) if f.get("code", "").isdigit() else 0
|
||||
self._apply_status(new, code, line, f.get("reason", ""))
|
||||
|
||||
def _apply_status(self, status: str, code: int, line: LogLine, reason: str) -> None:
|
||||
self.status = (status, code)
|
||||
if not self.led_exact:
|
||||
self.led, self.led_pattern = kota.led_for_status(status, code)
|
||||
self.led_reason = f"status {status}" + (f" code={code}" if code else "") + (
|
||||
f", reason: {reason}" if reason else "")
|
||||
sf = kota.status_finding(status, code)
|
||||
key = sf[0] if sf else None
|
||||
for fd in self.findings.values():
|
||||
if fd.rule_id.startswith("status.") and fd.key != key and not fd.resolved:
|
||||
fd.resolved, fd.resolved_by = True, f"status -> {status}"
|
||||
if sf:
|
||||
key, sev, title, meaning, actions, expl = sf
|
||||
fd = self._custom(key, Severity[sev.upper()], title, meaning, actions, expl)
|
||||
self._touch(fd, line)
|
||||
|
||||
def _kota_cmd(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
"""Replies to commands sent by this tool (firmware usb_console)."""
|
||||
name = line.words[0] if line.words else ""
|
||||
if f.get("ok") != "1":
|
||||
return # the Wi-Fi tab reports failures itself
|
||||
if name == "ping":
|
||||
api = f.get("api", "")
|
||||
self.console_api = int(api) if api.isdigit() else None
|
||||
elif name == "health":
|
||||
self.boot.kota["health"] = line # same fields as the periodic health line
|
||||
self._kota_health(line, f)
|
||||
elif name == "wifi_get":
|
||||
self.wifi_config = {k: f.get(k, "?") for k in ("ssid", "password_set", "mode")}
|
||||
|
||||
def _kota_led(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
if "previous_boot" in line.words:
|
||||
code = f.get("code", "0")
|
||||
self.boot.previous_status = (f.get("status", "?"), int(code) if code.isdigit() else 0)
|
||||
return
|
||||
if "restart_flash" in line.words:
|
||||
return
|
||||
colour = kota.LED_COLOURS.get(f.get("color", ""))
|
||||
if colour is None:
|
||||
return
|
||||
pattern = kota.pattern_text(f)
|
||||
self.led, self.led_pattern, self.led_exact = ("OFF", "", True) if pattern == "off" else (colour, pattern, True)
|
||||
if not self.led_reason or self.status is None:
|
||||
self.led_reason = "reported by the box"
|
||||
|
||||
def _kota_health(self, line: LogLine, f: dict[str, str]) -> None:
|
||||
up = kota.parse_seconds(f.get("up", "")) or 0
|
||||
status = f.get("status", "")
|
||||
# Connected mid-run: the health line is the first exact status we get.
|
||||
if status and (self.status is None or self.status[0] != status):
|
||||
self._apply_status(status, 0, line, "from the health report")
|
||||
mode = self.boot.mode
|
||||
wifi = f.get("wifi")
|
||||
rssi = int(f["rssi"]) if f.get("rssi", "").lstrip("-").isdigit() else None
|
||||
if wifi == "connected":
|
||||
self._milestone("wifi_connected", line)
|
||||
self._resolve("kota.wifi_down", "Wi-Fi connected")
|
||||
if rssi is not None and rssi < -75:
|
||||
self._raise("kota.weak_wifi", line, {"rssi": str(rssi)})
|
||||
elif rssi is not None:
|
||||
self._resolve("kota.weak_wifi", "signal OK")
|
||||
elif wifi == "disconnected" and mode != "provisioning":
|
||||
self._raise("kota.wifi_down", line)
|
||||
if f.get("time_valid") == "1":
|
||||
self._milestone("time_synced", line)
|
||||
self._resolve("kota.time_invalid", "time valid")
|
||||
elif f.get("time_valid") == "0" and up >= 120:
|
||||
self._raise("kota.time_invalid", line)
|
||||
if f.get("sd") == "missing":
|
||||
self._raise("kota.sd_unmounted", line, {"err": "reported missing by the health check"})
|
||||
last = f.get("last_upload", "never")
|
||||
if last != "never":
|
||||
self._milestone("data_uploaded", line)
|
||||
self._resolve("kota.no_upload", "data uploaded")
|
||||
elif wifi == "connected" and up >= 300:
|
||||
self._raise("kota.no_upload", line)
|
||||
heap = f.get("heap", "")
|
||||
if heap.isdigit() and int(heap) < 20_000:
|
||||
self._raise("kota.low_heap", line, {"heap": heap})
|
||||
ota = f.get("ota")
|
||||
if ota == "registered":
|
||||
self._milestone("ota_registered", line)
|
||||
self._resolve("kota.ota_unregistered", "registered")
|
||||
elif ota == "unregistered" and wifi == "connected" and mode != "provisioning":
|
||||
self._raise("kota.ota_unregistered", line)
|
||||
|
||||
def _hit_error_id(self, line: LogLine, info: kota.ErrorId | None) -> None:
|
||||
eid = line.error_id or "?"
|
||||
msg = (line.message or "").strip()
|
||||
msg = msg if len(msg) <= 90 else msg[:87] + "..."
|
||||
if info:
|
||||
sev = Severity.WARNING if info.severity == "warning" else Severity.ERROR
|
||||
f = self._custom(f"eid.{eid}", sev, f"{info.module_name}: {msg}",
|
||||
f"The firmware reported error {eid} ({info.module_name}).",
|
||||
(info.action, "If it repeats, save the session log for the firmware team."))
|
||||
f.rule_id = f"eid.{info.module}"
|
||||
f.error_ids[eid] = info.action
|
||||
else:
|
||||
f = self._custom(f"eid.{eid}", Severity.WARNING, f"Error {eid}: {msg}",
|
||||
"This error ID is not in the tool's error table (newer firmware?).",
|
||||
("Save the session log and send it to the firmware team.",))
|
||||
self._touch(f, line)
|
||||
|
||||
def _box_summary(self) -> list[tuple[str, str, str]]:
|
||||
"""What the box reported about itself in this boot (KOTA lines), for the panel."""
|
||||
k = self.boot.kota
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if ln := k.get("boot"):
|
||||
f = ln.fields
|
||||
lr = f.get("last_restart", "none")
|
||||
bad = kota.restart_finding(lr) is not None
|
||||
out.append(("Boot", f"v{f.get('fw', '?')} build {f.get('build', '?')}, mode {f.get('mode', '?')}, "
|
||||
f"reset {f.get('reset', '?')}, last restart: {lr}", "warn" if bad else "ok"))
|
||||
if ps := self.boot.previous_status:
|
||||
if ps[0] != "OK":
|
||||
out.append(("Before restart", f"status {ps[0]}" + (f" code={ps[1]}" if ps[1] else ""), "warn"))
|
||||
if ln := k.get("i2c"):
|
||||
f = ln.fields
|
||||
if f.get("bus") == "stuck":
|
||||
out.append(("I2C", f"bus stuck (SDA={f.get('SDA')}, SCL={f.get('SCL')})", "bad"))
|
||||
else:
|
||||
devs = [(v.removesuffix("(missing)"), v.endswith("(missing)"))
|
||||
for a, v in f.items() if a.startswith("0x") and v != "?"]
|
||||
miss = [n for n, m in devs if m]
|
||||
text = ("missing: " + ", ".join(miss) + "; " if miss else "") + "found: " + (
|
||||
", ".join(n for n, m in devs if not m) or "none")
|
||||
out.append(("I2C", text, "bad" if miss else "ok"))
|
||||
if ln := k.get("sd"):
|
||||
f = ln.fields
|
||||
if f.get("mounted") == "0":
|
||||
out.append(("SD card", "not mounted" + (f" ({f['err']})" if f.get("err") else ""), "bad"))
|
||||
else:
|
||||
keys = f.get("env_keys", "-")
|
||||
ok = "INFLUX_URL" in keys and "INFLUX_TOKEN" in keys
|
||||
out.append(("SD card", f"{f.get('type', '?')} {f.get('size', '?')} {f.get('fs', '?')}, "
|
||||
f"{f.get('free', '?')} free, ENV keys: {keys}, buffered: {f.get('buffer_lines', '?')}",
|
||||
"ok" if ok else "bad"))
|
||||
rtc, verify = k.get("rtc"), k.get("rtc.verify")
|
||||
if rtc or verify:
|
||||
parts, state = [], "ok"
|
||||
if rtc:
|
||||
if "read_failed" in rtc.words:
|
||||
parts.append(f"read failed ({rtc.fields.get('err', '?')})")
|
||||
state = "bad"
|
||||
else:
|
||||
running = rtc.fields.get("running") == "1"
|
||||
parts.append(("running" if running else "STOPPED") + f", time {rtc.fields.get('time', '?')}")
|
||||
state = "ok" if running else "warn"
|
||||
if verify:
|
||||
ok = verify.fields.get("ok") == "1"
|
||||
parts.append("verified ticking" if ok else "NOT keeping time")
|
||||
state = state if ok else "bad"
|
||||
out.append(("Clock (RTC)", "; ".join(parts), state))
|
||||
if ln := k.get("matter"):
|
||||
f = ln.fields
|
||||
fabrics = f.get("fabrics", "?")
|
||||
out.append(("Matter", f"{'paired' if fabrics not in ('0', '?') else 'NOT paired'} (fabrics {fabrics}), "
|
||||
f"pairing window {f.get('window', '?')}", "ok" if fabrics not in ("0", "?") else "warn"))
|
||||
if ln := k.get("mic"):
|
||||
on = ln.fields.get("enabled") == "1"
|
||||
out.append(("Microphone", "on" if on else "switched OFF (switch on the back)", "ok" if on else "info"))
|
||||
if self.wifi_config:
|
||||
from ..serialio.commands import unhex_utf8
|
||||
w = self.wifi_config
|
||||
ssid = unhex_utf8(w["ssid"])
|
||||
out.append(("Wi-Fi config", (f"network '{ssid}', password {'set' if w['password_set'] == '1' else 'NOT set'}"
|
||||
if ssid else "no network on the SD card") + f", mode {w['mode']}", "info"))
|
||||
if ln := k.get("health"):
|
||||
f = ln.fields
|
||||
wifi = f.get("wifi", "?")
|
||||
text = (f"up {f.get('up', '?')}, status {f.get('status', '?')}, Wi-Fi {wifi}"
|
||||
+ (f" ({f.get('rssi')} dBm)" if wifi == "connected" else "")
|
||||
+ f", time {'OK' if f.get('time_valid') == '1' else 'NOT set'}, SD {f.get('sd', '?')}, "
|
||||
f"buffer {f.get('buffer', '?')}, heap {f.get('heap', '?')}, last upload {f.get('last_upload', '?')}"
|
||||
+ ("" if f.get("last_upload") == "never" else " ago") + f", OTA {f.get('ota', '?')}")
|
||||
good = f.get("status") == "OK" and wifi == "connected" and f.get("last_upload") != "never"
|
||||
out.append(("Health", text, "ok" if good else "warn"))
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------ reboots
|
||||
def _reboot_findings(self) -> list[Finding]:
|
||||
"""Classify why each earlier boot ended (needs the next boot's reset reason)."""
|
||||
out: list[Finding] = []
|
||||
short_unexpected: list[BootRecord] = []
|
||||
cur = self.boot
|
||||
# The box keeps its status across a restart ("led previous_boot"): if
|
||||
# it was in an error state right before the latest restart (often the
|
||||
# technician's Reset & capture), that state is worth showing.
|
||||
if cur.previous_status and cur.previous_status[0] not in ("OK", "UPDATING", "SELF_CHECK"):
|
||||
sf = kota.status_finding(*cur.previous_status)
|
||||
st, code = cur.previous_status
|
||||
# Only informative once the box reports OK again in this boot.
|
||||
sev = Severity.INFO if self.status and self.status[0] == "OK" else Severity.WARNING
|
||||
f = Finding("boot.previous_status", "boot.previous_status", sev,
|
||||
f"Before the last restart: {sf[2] if sf else st}",
|
||||
"The box remembers its status across a restart. Right before the latest restart it was in "
|
||||
f"state {st}" + (f" code={code}" if code else "") + ". "
|
||||
+ (sf[3] if sf else "") + " Check whether the problem comes back in this boot.",
|
||||
sf[4] if sf else ())
|
||||
f.count, f.boot, f.last_seen = 1, cur.index, cur.host_time
|
||||
f.evidence.append(f"led previous_boot status={st} code={code}")
|
||||
out.append(f)
|
||||
for prev, nxt in zip(self.boots, self.boots[1:]):
|
||||
if not prev.saw_log and prev.index == 0:
|
||||
continue # nothing captured before the first boot
|
||||
reason = nxt.reset_reason or "unknown"
|
||||
if nxt.tool_reset or prev.intentional_restart:
|
||||
continue
|
||||
# Firmware 1.0.16+ records why it restarted itself.
|
||||
last = nxt.last_restart or prev.restart_reason
|
||||
if last in kota.INTENTIONAL_RESTARTS:
|
||||
continue
|
||||
key, sev, title, meaning, actions, expl = None, Severity.INFO, "", "", (), ()
|
||||
if "USB_UART_CHIP_RESET" in reason:
|
||||
rf = kota.restart_finding(last) if last else None
|
||||
if prev.brownout or "BROWN" in reason or last == "brownout":
|
||||
# A crash right after a brown-out is its effect, not a firmware bug.
|
||||
key, sev, title = "reboot.brownout", Severity.CRITICAL, "Power supply voltage drops (brown-out)"
|
||||
meaning = ("The supply voltage fell too low and the chip reset itself - typically when the "
|
||||
"Wi-Fi/Bluetooth radio starts. The box cannot run like this.")
|
||||
actions = ("Switch the box's power switch ON - USB from a laptop alone may not be enough.",
|
||||
"Check the power supply, cable and connectors; try another supply.",
|
||||
"If it persists on a good supply: the board's power circuit is faulty.")
|
||||
elif rf and not (last == "panic" and prev.panic_lines):
|
||||
sev_name, title, meaning, actions = rf
|
||||
sev = Severity[sev_name.upper()]
|
||||
key = {"status_watchdog": "reboot.status_watchdog", "panic": "reboot.crash",
|
||||
"brownout": "reboot.brownout", "task_wdt": "reboot.watchdog", "int_wdt": "reboot.watchdog",
|
||||
"wdt": "reboot.watchdog"}.get(last, f"reboot.{last}")
|
||||
if nxt.previous_status and nxt.previous_status[0] != "OK":
|
||||
st, code = nxt.previous_status
|
||||
meaning += f" Before the restart the box was in state {st}" + (f" code={code}" if code else "") + "."
|
||||
if last == "status_watchdog":
|
||||
expl = tuple(_INIT_FAILURES) + kota._SENSOR_ROOTS + ("status.error", "sys.out_of_resources")
|
||||
elif last == "status_oom_watchdog":
|
||||
expl = ("sys.out_of_resources",)
|
||||
elif rf:
|
||||
pass # panic with captured crash output: covered by sys.crash
|
||||
elif "USB_UART_CHIP_RESET" in reason:
|
||||
key, title = "reboot.usb", "Box was reset over USB"
|
||||
meaning = "The board was reset through its USB port (a flashing tool, a serial program, or this tool in an earlier session)."
|
||||
elif "POWERON" in reason:
|
||||
key, title = "reboot.power", "Box was switched off and on"
|
||||
meaning = "Power was interrupted (power switch, cable or supply)."
|
||||
actions = ("If nobody switched it off: check the power supply and cable.",)
|
||||
elif "BROWN" in reason:
|
||||
key, sev, title = "reboot.brownout", Severity.ERROR, "Power supply voltage dropped (brown-out)"
|
||||
meaning = "The supply voltage fell too low and the chip reset itself."
|
||||
actions = ("Check the power supply, cable and connectors.",)
|
||||
elif "WDT" in reason:
|
||||
key, sev, title = "reboot.watchdog", Severity.ERROR, "Firmware froze and was restarted by the watchdog"
|
||||
meaning = "The firmware stopped responding; the hardware watchdog restarted it."
|
||||
actions = ("Save the session log for the firmware team.",)
|
||||
elif prev.panic_lines:
|
||||
pass # covered by sys.crash
|
||||
elif prev.led_at_end in ("PURPLE", "RED") and prev.last_uptime_ms >= 140_000:
|
||||
elif (not nxt.last_restart and prev.led_at_end in ("PURPLE", "RED")
|
||||
and prev.last_uptime_ms >= 140_000): # older firmware: no restart record
|
||||
key, title = "reboot.status_watchdog", "Box restarted itself after 2.5 minutes of errors"
|
||||
meaning = "By design the firmware restarts when it stays in an error state (purple/red) for 150 s."
|
||||
actions = ("Fix the grouped cause; the restarts stop by themselves.",)
|
||||
@@ -540,10 +970,18 @@ class DiagnosticsEngine:
|
||||
"reboot.loop", Severity.CRITICAL, "Reboot loop",
|
||||
f"The box restarted {len(short_unexpected)} times, about every {avg:.0f} s. "
|
||||
"The error printed just before each restart is the cause.",
|
||||
("Fix the grouped cause below.",), tuple(_INIT_FAILURES), self.boot)
|
||||
("Fix the grouped cause below.",), tuple(_INIT_FAILURES) + ("reboot.brownout",), self.boot)
|
||||
f.count = len(short_unexpected)
|
||||
out.append(f)
|
||||
return out
|
||||
# One finding per cause, counting how often it happened.
|
||||
merged: dict[str, Finding] = {}
|
||||
for f in out:
|
||||
if f.key in merged:
|
||||
merged[f.key].count += f.count
|
||||
merged[f.key].evidence.extend(f.evidence)
|
||||
else:
|
||||
merged[f.key] = f
|
||||
return list(merged.values())
|
||||
|
||||
def _reboot_finding(self, key, sev, title, meaning, actions, expl, boot: BootRecord) -> Finding:
|
||||
f = Finding(key, key, sev, title, meaning, actions, expl)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
id,module,severity,message,action
|
||||
E-SD-01,SD,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-SD-02,SD,error,"Failed to mount filesystem. If you want the card to be formatted, set the CONFIG_EXAMPLE_FORMAT_IF_MOUNT_FAILED menuconfig option.",SD card is not FAT formatted or is corrupt; reformat it as FAT32 and restore ENV.TXT
|
||||
E-SD-03,SD,error,Failed to initialize the card (%s). Make sure SD card lines have pull-up resistors in place.,Reseat the SD card; if it persists replace the card or check the SD connector
|
||||
E-SD-04,SD,error,Failed to stat %s,Reseat the SD card; if it persists replace the card or check the SD connector
|
||||
E-SD-05,SD,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-ENV-01,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-ENV-02,ENV,error,Failed to open .env file for reading,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||
E-ENV-03,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-ENV-04,ENV,error,Failed to open existing .env file for reading; aborting write to avoid losing other variables,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||
E-ENV-05,ENV,error,Failed to open .env file for writing,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||
E-ENV-06,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-ENV-07,ENV,error,Failed to open existing .env file for reading; aborting delete to avoid losing other variables,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||
E-ENV-08,ENV,error,Failed to open .env file for writing,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||
E-ENV-09,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-INF-01,INF,error,Error %d at %s:%d,Internal error - send the log to the firmware team
|
||||
E-INF-02,INF,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
|
||||
E-INF-03,INF,error,"Failed to perform HTTP request: %d, %s",Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-04,INF,error,"Bad request, check line protocol format: %s",Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-05,INF,error,Failed to read INFLUX_URL from .env file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-06,INF,error,Failed to read INFLUX_TOKEN from .env file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-07,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-INF-08,INF,error,Failed to open buffer file for reading,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-09,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-INF-10,INF,error,Failed to open buffer file for clearing,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-11,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-INF-12,INF,error,Failed to create buffer file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-13,INF,error,Failed to open buffer file for appending,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-14,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-INF-15,INF,error,Failed to open buffer file for counting,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-16,INF,error,Failed to read buffer for flushing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-17,INF,error,Failed to write chunk to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-18,INF,error,Failed to write chunk with 5 retries to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-19,INF,error,Failed to clear buffer after flushing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-20,INF,error,Failed to flush buffer before writing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-21,INF,error,Failed to write to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-INF-22,INF,error,Failed to append new entry to buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-OTA-01,OTA,error,Error %s at %s:%d,Internal error - send the log to the firmware team
|
||||
E-OTA-02,OTA,error,Failed to create OTA task,Internal error - send the log to the firmware team
|
||||
E-OTA-03,OTA,error,Failed to open NVS: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-04,OTA,error,Failed to read OTA token from NVS: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-05,OTA,error,Failed to read OTA token from NVS: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-06,OTA,error,Failed to get running partition,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-07,OTA,error,Failed to get OTA state,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-08,OTA,error,Failed to read update metadata from NVS: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-09,OTA,error,Failed to report failed selfcheck: %s,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-10,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
|
||||
E-OTA-11,OTA,error,Device registration failed with status code: %d,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-12,OTA,error,Device registration failed: %s,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-13,OTA,error,Failed to check for updates: %s,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-14,OTA,error,Free stack size decreased from %u to %u,Internal error - send the log to the firmware team
|
||||
E-OTA-15,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
|
||||
E-OTA-16,OTA,error,OTA Update check failed with status code: %d,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-17,OTA,error,"Update %s has already been tried %u times and failed, not trying again",Internal error - send the log to the firmware team
|
||||
E-OTA-18,OTA,error,OTA update failed: %s,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-19,OTA,error,Failed to read update metadata from NVS: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-20,OTA,error,Failed to read update ID from NVS: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-21,OTA,error,Failed to read update ID from NVS: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-22,OTA,error,Failed to read update tries from NVS: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-23,OTA,error,Failing selfcheck: %s,Firmware update failed its self check and will roll back; check the sensors listed in the log
|
||||
E-OTA-24,OTA,error,Failed to send update result: %s,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-25,OTA,error,Failed to mark app as invalid for rollback: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-26,OTA,error,Failed to send update result: %s,Check the network connection; if it persists send the log to the firmware team
|
||||
E-OTA-27,OTA,error,Failed to mark app as valid to commit update: %s,Internal error - send the log to the firmware team
|
||||
E-OTA-28,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
|
||||
E-OTA-29,OTA,error,Failed to send update result with status code: %d,Check the network connection; if it persists send the log to the firmware team
|
||||
E-WIFI-01,WIFI,error,esp_netif_init() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-02,WIFI,error,esp_event_loop_create_default() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-03,WIFI,error,esp_wifi_init() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-04,WIFI,error,Failed to register WIFI_EVENT handler: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-05,WIFI,error,Failed to register IP_EVENT handler: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-06,WIFI,error,esp_wifi_set_mode() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-07,WIFI,error,esp_timer_create() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-08,WIFI,error,esp_wifi_start() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-09,WIFI,error,esp_wifi_set_config() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-10,WIFI,error,esp_wifi_connect() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-11,WIFI,error,esp_wifi_set_mode(APSTA) failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-12,WIFI,error,esp_wifi_set_config(AP) failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-13,WIFI,error,esp_wifi_connect() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-14,WIFI,error,esp_wifi_scan_start() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-15,WIFI,error,esp_wifi_scan_get_ap_num() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-WIFI-16,WIFI,error,esp_wifi_scan_get_ap_records() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-PROV-01,PROV,error,mdns_init() failed: %s,Retry WiFi provisioning (short button press); restart the device if it persists
|
||||
E-PROV-02,PROV,error,httpd_start() failed: %s,Retry WiFi provisioning (short button press); restart the device if it persists
|
||||
E-MAIN-01,MAIN,error,Error %d at %s:%d,Internal error - send the log to the firmware team
|
||||
E-MAIN-02,MAIN,error,"Failed to open commissioning window, err:%",Internal error - send the log to the firmware team
|
||||
E-MAIN-03,MAIN,error,"Attribute update callback: endpoint_id: %u, cluster_id: 0x%lx, attribute_id: 0x%lx",Internal error - send the log to the firmware team
|
||||
E-MAIN-04,MAIN,error,Device MAC address: %02X:%02X:%02X:%02X:%02X:%02X,Internal error - send the log to the firmware team
|
||||
E-MAIN-05,MAIN,error,Failed to initialize SD card: %s,Reseat the SD card; if it persists replace the card or check the SD connector
|
||||
E-MAIN-06,MAIN,error,Failed to initialize EnvironmentService: %s,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||
E-MAIN-07,MAIN,error,Failed to initialize WifiCredentialsService: %s,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
|
||||
E-MAIN-08,MAIN,error,WiFi provisioning server failed to start: %s; clearing HTTP_SERVER and restarting,Retry WiFi provisioning (short button press); restart the device if it persists
|
||||
E-MAIN-09,MAIN,error,Failed to create Matter node,Internal error - send the log to the firmware team
|
||||
E-MAIN-10,MAIN,error,Failed to start ESP-Matter,Internal error - send the log to the firmware team
|
||||
E-MAIN-11,MAIN,error,Failed to start WifiService: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
|
||||
E-MAIN-12,MAIN,error,"Failed to initialized the encrypted OTA, err: %d",Internal error - send the log to the firmware team
|
||||
E-MAIN-13,MAIN,error,Failed to initialize RTC: %s,Check the RTC (DS1307) and the sensor cable
|
||||
E-MAIN-14,MAIN,error,Failed to initialize Influx buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-MAIN-15,MAIN,error,Failed to initialize BME688: %s,Check the BME688 sensor and the sensor cable
|
||||
E-MAIN-16,MAIN,error,Failed to initialize SEN66: %s,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-MAIN-17,MAIN,error,Failed to initialize AS7341: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-MAIN-18,MAIN,error,Failed to initialize Microphone: %s,Check the microphone board and its cable
|
||||
E-MAIN-19,MAIN,error,Failed to initialize Flicker sensor: %s,Check the flicker sensor and its cable
|
||||
E-MAIN-20,MAIN,error,Failed to initialize DeviceStatus: %s,Internal error - send the log to the firmware team
|
||||
E-MAIN-21,MAIN,error,BME688 self check failed: %s,Check the BME688 sensor and the sensor cable
|
||||
E-MAIN-22,MAIN,error,SEN66 self check failed: %s,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-MAIN-23,MAIN,error,AS7341 self check failed: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-MAIN-24,MAIN,error,Microphone self check failed: %s,Check the microphone board and its cable
|
||||
E-MAIN-25,MAIN,error,Flicker sensor self check failed: %s,Check the flicker sensor and its cable
|
||||
E-MAIN-26,MAIN,error,Failed to report passed selfcheck: %s,Internal error - send the log to the firmware team
|
||||
E-MAIN-27,MAIN,error,Failed to flush Influx buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
|
||||
E-STAT-01,STAT,error,Failed to initialize LED strip: %s,Internal error - send the log to the firmware team
|
||||
E-SPI-01,SPI,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
|
||||
E-RTC-01,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-RTC-02,RTC,error,Failed to add I2C device: %s,Check the RTC (DS1307) and the sensor cable
|
||||
E-RTC-03,RTC,error,Failed to initialize DS1307 control register: %s,Check the RTC (DS1307) and the sensor cable
|
||||
E-RTC-04,RTC,error,Attempted to set time on uninitialized DS1307,Internal error - send the log to the firmware team
|
||||
E-RTC-05,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-RTC-06,RTC,error,Attempted to get time on uninitialized DS1307,Internal error - send the log to the firmware team
|
||||
E-RTC-07,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-RTC-08,RTC,error,Attempted to start updating time on uninitialized DS1307,Internal error - send the log to the firmware team
|
||||
E-BME-01,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-BME-02,BME,error,Failed to add I2C device: %d,Check the BME688 sensor and the sensor cable
|
||||
E-BME-03,BME,error,Failed to initialize BME688: %d,Check the BME688 sensor and the sensor cable
|
||||
E-BME-04,BME,error,Failed to set BME688 configuration: %d,Check the BME688 sensor and the sensor cable
|
||||
E-BME-05,BME,error,Failed to set BME688 heater configuration: %d,Check the BME688 sensor and the sensor cable
|
||||
E-BME-06,BME,error,Failed to set BME688 operation mode: %d,Check the BME688 sensor and the sensor cable
|
||||
E-BME-07,BME,error,Failed to get BME688 data: %d,Check the BME688 sensor and the sensor cable
|
||||
E-BME-08,BME,error,No new data available from BME688,Check the BME688 sensor and the sensor cable
|
||||
E-BME-09,BME,error,Failed to get data from BME688: %s,Check the BME688 sensor and the sensor cable
|
||||
E-BME-10,BME,error,Self check failed: %s,Check the BME688 sensor and the sensor cable
|
||||
E-BME-11,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-BME-12,BME,error,I2C read failed: %s,Check the BME688 sensor and the sensor cable
|
||||
E-BME-13,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-BME-14,BME,error,Failed to allocate memory for I2C write buffer,Internal error - send the log to the firmware team
|
||||
E-BME-15,BME,error,I2C write failed: %s,Check the BME688 sensor and the sensor cable
|
||||
E-SEN-01,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-SEN-02,SEN,error,Failed to add I2C device: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-03,SEN,error,Attempted to wake up uninitialized SEN66,Internal error - send the log to the firmware team
|
||||
E-SEN-04,SEN,error,Failed to take sensor mutex,Internal error - send the log to the firmware team
|
||||
E-SEN-05,SEN,error,Failed to send start measurement command: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-06,SEN,error,Attempted to sleep uninitialized SEN66,Internal error - send the log to the firmware team
|
||||
E-SEN-07,SEN,error,Failed to take sensor mutex,Internal error - send the log to the firmware team
|
||||
E-SEN-08,SEN,error,Failed to send stop measurement command: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-09,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-SEN-10,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-SEN-11,SEN,error,Failed to read ready status: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-12,SEN,error,"Sensor not ready after %d attempts, trying to wake it up","Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-13,SEN,error,Failed to read measurement: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-14,SEN,error,Failed while awaiting sensor ready: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-15,SEN,error,Failed to read measurement for line protocol: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-16,SEN,error,Self-check failed: Sensor not ready: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-SEN-17,SEN,error,Self-check failed: Failed to read measurement: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
|
||||
E-AS-01,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-AS-02,AS,error,Failed to initialize AS7341: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-AS-03,AS,error,Attempted to wake up uninitialized AS7341,Internal error - send the log to the firmware team
|
||||
E-AS-04,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-AS-05,AS,error,Failed to wake up AS7341: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-AS-06,AS,error,Attempted to put uninitialized AS7341 to sleep,Internal error - send the log to the firmware team
|
||||
E-AS-07,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-AS-08,AS,error,Failed to put AS7341 to sleep: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-AS-09,AS,error,Attempted to get line protocol on uninitialized AS7341,Internal error - send the log to the firmware team
|
||||
E-AS-10,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-AS-11,AS,error,Failed to read spectral data: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-AS-12,AS,error,Failed to read basic counts data: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-AS-13,AS,error,Failed to read flicker detection state: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-AS-14,AS,error,Self-check failed: Failed to read spectral data: %s,Check the AS7341 light sensor and the sensor cable
|
||||
E-MIC-01,MIC,error,Failed to create I2S channel: %s,Check the microphone board and its cable
|
||||
E-MIC-02,MIC,error,Failed to initialize I2S channel in standard mode: %s,Check the microphone board and its cable
|
||||
E-MIC-03,MIC,error,Failed to configure shutdown pin: %s,Check the microphone board and its cable
|
||||
E-MIC-04,MIC,error,Failed to read samples from I2S channel: %s,Check the microphone board and its cable
|
||||
E-MIC-05,MIC,error,Failed to read samples for line protocol: %s,Check the microphone board and its cable
|
||||
E-FLK-01,FLK,error,Error %s at %s:%d,Internal error - send the log to the firmware team
|
||||
E-FLK-02,FLK,error,Attempting to initialize sensor that is already initialized,Check the flicker sensor and its cable
|
||||
E-FLK-03,FLK,error,Failed to allocate memory for FFT buffers,Internal error - send the log to the firmware team
|
||||
E-FLK-04,FLK,error,Failed to initialize FFT: %s,Check the flicker sensor and its cable
|
||||
E-FLK-05,FLK,error,Attempting to read from uninitialized sensor,Internal error - send the log to the firmware team
|
||||
E-FLK-06,FLK,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
|
||||
E-FLK-07,FLK,error,Failed to get dominant frequency: %s,Check the flicker sensor and its cable
|
||||
E-FLK-08,FLK,error,Attempting to read from uninitialized sensor,Internal error - send the log to the firmware team
|
||||
E-FLK-09,FLK,error,Failed to read ADC value: %s,Check the flicker sensor and its cable
|
||||
E-FLK-10,FLK,error,Failed to create gather task: %s,Internal error - send the log to the firmware team
|
||||
E-FLK-11,FLK,error,Attempting to self check uninitialized sensor,Internal error - send the log to the firmware team
|
||||
E-FLK-12,FLK,error,Self check failed to get dominant frequency: %s,Check the flicker sensor and its cable
|
||||
E-DEV-01,DEV,error,Failed to initialize ADC unit: %s,Internal error - send the log to the firmware team
|
||||
E-DEV-02,DEV,error,Failed to configure ADC channel: %s,Internal error - send the log to the firmware team
|
||||
E-DEV-03,DEV,error,Failed to install temperature sensor: %s,Internal error - send the log to the firmware team
|
||||
E-DEV-04,DEV,error,Failed to read ADC value: %s,Internal error - send the log to the firmware team
|
||||
E-DEV-05,DEV,error,Failed to allocate memory for task list buffer,Internal error - send the log to the firmware team
|
||||
E-GATH-01,GATH,error,Failed to take line protocol mutex,Internal error - send the log to the firmware team
|
||||
E-GATH-02,GATH,error,Failed to get line protocol from sensor,Device ran out of memory; restart it and send the log to the firmware team
|
||||
E-GATH-03,GATH,error,Failed to create task for sensor,Internal error - send the log to the firmware team
|
||||
E-GATH-04,GATH,error,Failed to create main gather task,Internal error - send the log to the firmware team
|
||||
E-CON-01,CON,error,Failed to install USB-Serial-JTAG driver: %s,Internal error - send the log to the firmware team
|
||||
|
@@ -1,8 +1,14 @@
|
||||
"""Firmware identification from the boot log.
|
||||
|
||||
The app version string is "1.0" in every build seen so far, so builds are
|
||||
told apart by the ELF SHA256 prefix printed by app_init. Boxes receive new
|
||||
firmware over OTA; an unknown SHA usually means a box has already updated.
|
||||
Up to firmware 1.0 the app version string was "1.0" in every build, so
|
||||
builds are told apart by the ELF SHA256 prefix printed by app_init. Boxes
|
||||
receive new firmware over OTA; an unknown SHA usually means a box has already
|
||||
updated.
|
||||
|
||||
Firmware 1.0.16+ prints "KOTA: boot fw=1.0.16 build=4f10989e ..." (version
|
||||
unique per build, first 8 hex digits of the ELF SHA256). Builds found in the
|
||||
tool's firmware/ folder are registered with `register_local_build` so the
|
||||
box's build can be shown with its compile date.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,6 +40,27 @@ KNOWN: dict[str, KnownFirmware] = {
|
||||
),
|
||||
}
|
||||
|
||||
# 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>.+)$")
|
||||
@@ -51,8 +78,21 @@ class FirmwareInfo:
|
||||
version: str | None = None
|
||||
built: str | None = None
|
||||
idf: str | None = None
|
||||
hw: str | None = None
|
||||
evidence: set[str] = field(default_factory=set)
|
||||
|
||||
def observe_kota(self, fields: dict[str, str]) -> None:
|
||||
"""'KOTA: boot fw=1.0.16 build=4f10989e hw=2 ...'"""
|
||||
if fields.get("fw"):
|
||||
self.version = fields["fw"]
|
||||
build = fields.get("build", "")
|
||||
# app_init's longer prefix is kept if it agrees.
|
||||
if build and not same_build(self.elf_sha, build):
|
||||
self.elf_sha = build.lower()
|
||||
if fields.get("hw"):
|
||||
self.hw = fields["hw"]
|
||||
self.evidence.add("kota") # 1.0.16+: SD Wi-Fi and setup portal exist
|
||||
|
||||
def observe(self, text: str) -> None:
|
||||
if m := _ELF.search(text):
|
||||
self.elf_sha = m.group("sha")
|
||||
@@ -73,14 +113,14 @@ class FirmwareInfo:
|
||||
def known(self) -> KnownFirmware | None:
|
||||
if not self.elf_sha:
|
||||
return None
|
||||
return next((k for sha, k in KNOWN.items() if self.elf_sha.startswith(sha)), None)
|
||||
return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None)
|
||||
|
||||
@property
|
||||
def supports_sd_wifi(self) -> bool | None:
|
||||
"""True/False when determinable, None when unknown (no boot seen)."""
|
||||
if k := self.known:
|
||||
return k.sd_wifi
|
||||
if "sd_wifi" in self.evidence:
|
||||
if "sd_wifi" in self.evidence or "kota" in self.evidence:
|
||||
return True
|
||||
if "legacy_v1" in self.evidence:
|
||||
return False
|
||||
@@ -89,6 +129,16 @@ class FirmwareInfo:
|
||||
def describe(self) -> str:
|
||||
if k := self.known:
|
||||
return f"{k.name} - {k.source}"
|
||||
if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)):
|
||||
local = local_build(self.elf_sha)
|
||||
built = self.built or (local[1] if local else None)
|
||||
text = f"v{self.version or (local[0] if local else '?')} (build {self.elf_sha[:8]}"
|
||||
text += f", compiled {built})" if built else ")"
|
||||
if self.hw:
|
||||
text += f", hardware {self.hw}"
|
||||
if local:
|
||||
text += f" - same as firmware/{local[2]}"
|
||||
return text
|
||||
if self.elf_sha:
|
||||
built = f", built {self.built}" if self.built else ""
|
||||
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
|
||||
|
||||
@@ -4,7 +4,8 @@ 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.
|
||||
report and the session log. "influx" steps ask the host to query InfluxDB
|
||||
(on_action("influx")); the host answers with influx_result().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,10 +22,12 @@ from .engine import LED, DiagnosticsEngine, Severity
|
||||
|
||||
GUIDES_PATH = Path(__file__).with_name("guides.toml")
|
||||
# LED answers a technician can give (engine colours + a reboot-loop pattern).
|
||||
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "OFF", "CYCLING"]
|
||||
LED_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"}
|
||||
STEP_TYPES = {"led", "question", "info", "wait", "route", "influx"}
|
||||
# Results of an InfluxDB check (service.influx.OUTCOMES).
|
||||
INFLUX_OUTCOMES = ("ok", "sensors_missing", "silent", "never", "unavailable")
|
||||
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
|
||||
"error": Severity.ERROR, "critical": Severity.CRITICAL}
|
||||
|
||||
@@ -58,9 +61,12 @@ class Step:
|
||||
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
|
||||
outcomes: dict[str, str] = field(default_factory=dict) # influx result -> target
|
||||
poll_s: float = 30.0 # influx: query again after this many seconds
|
||||
|
||||
def targets(self) -> list[str]:
|
||||
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
||||
t += list(self.outcomes.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
|
||||
|
||||
@@ -103,6 +109,7 @@ def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
|
||||
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)),
|
||||
outcomes=dict(s.get("outcomes", {})), poll_s=float(s.get("poll_s", 30)),
|
||||
)
|
||||
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
||||
for e in g.get("end", [])}
|
||||
@@ -123,6 +130,10 @@ def _validate(guides: dict[str, Guide]) -> None:
|
||||
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.type == "influx" and set(INFLUX_OUTCOMES) - set(s.outcomes):
|
||||
raise ValueError(f"{g.id}.{s.id}: influx step needs outcomes {set(INFLUX_OUTCOMES) - set(s.outcomes)}")
|
||||
if set(s.outcomes) - set(INFLUX_OUTCOMES):
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown influx outcomes {set(s.outcomes) - set(INFLUX_OUTCOMES)}")
|
||||
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():
|
||||
@@ -191,6 +202,10 @@ class GuideRunner:
|
||||
self._wait_started = 0.0
|
||||
self._wait_boot = 0
|
||||
self._wait_milestones: set[str] = set()
|
||||
self._influx_pending = False
|
||||
self._influx_next = 0.0
|
||||
self._influx_last: str | None = None
|
||||
self._influx_noted = ""
|
||||
|
||||
def start(self, guide_id: str) -> None:
|
||||
self.cancel()
|
||||
@@ -227,6 +242,28 @@ class GuideRunner:
|
||||
elif s.type == "wait":
|
||||
self._record(s, "technician skipped the wait")
|
||||
self._finish_wait("timeout")
|
||||
elif s.type == "influx":
|
||||
self._record(s, "technician skipped the InfluxDB check")
|
||||
self._finish_influx(self._influx_last or "unavailable")
|
||||
|
||||
def influx_result(self, outcome: str, summary: str, details: list[str] | tuple[str, ...] = ()) -> None:
|
||||
"""Result of the host's InfluxDB query for the current influx step."""
|
||||
s = self.step
|
||||
if s is None or s.type != "influx" or not self._influx_pending:
|
||||
return # stale answer: the technician moved on
|
||||
self._influx_pending = False
|
||||
self._influx_last = outcome
|
||||
note = "InfluxDB: " + "; ".join([summary, *details])
|
||||
if note != self._influx_noted: # polls repeat the same answer
|
||||
self._influx_noted = note
|
||||
self.engine.note_technician(note)
|
||||
self._on_log(f"InfluxDB check: {summary}")
|
||||
waited = self._clock() - self._wait_started
|
||||
if outcome in ("ok", "unavailable") or waited >= s.timeout_s:
|
||||
self._finish_influx(outcome)
|
||||
else:
|
||||
self._influx_next = self._clock() + s.poll_s
|
||||
self.note = f"{summary}. Checking again in {s.poll_s:.0f} s..."
|
||||
|
||||
def back(self) -> None:
|
||||
# Never step back into a wait: re-entering it could restart the box.
|
||||
@@ -256,6 +293,11 @@ class GuideRunner:
|
||||
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")
|
||||
elif s is not None and s.type == "influx" and not self._influx_pending:
|
||||
if self._clock() - self._wait_started >= s.timeout_s:
|
||||
self._finish_influx(self._influx_last or "unavailable")
|
||||
elif self._clock() >= self._influx_next:
|
||||
self._request_influx()
|
||||
|
||||
# --------------------------------------------------------------- view
|
||||
def view(self) -> View:
|
||||
@@ -268,7 +310,7 @@ class GuideRunner:
|
||||
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":
|
||||
elif s.type in ("wait", "influx"):
|
||||
v.waiting = True
|
||||
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
|
||||
return v
|
||||
@@ -316,6 +358,27 @@ class GuideRunner:
|
||||
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")
|
||||
if s.type == "influx":
|
||||
self._wait_started = self._clock()
|
||||
self._influx_last = None
|
||||
self._request_influx()
|
||||
|
||||
def _request_influx(self) -> None:
|
||||
self._influx_pending = True
|
||||
if not self._on_action("influx"):
|
||||
self._influx_pending = False
|
||||
self._influx_last = "unavailable"
|
||||
self._record(self.step, "InfluxDB check not possible from this computer")
|
||||
self._finish_influx("unavailable")
|
||||
|
||||
def _finish_influx(self, outcome: str) -> None:
|
||||
s = self.step
|
||||
self._influx_pending = False
|
||||
target = s.outcomes.get(outcome)
|
||||
if target is None:
|
||||
return
|
||||
self._push()
|
||||
self._go(target)
|
||||
|
||||
def _auto(self, s: Step, why: str, target: str | None) -> None:
|
||||
self._on_log(f"technician guide: {s.id}: {why}")
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
# counts as ok (used for "watch for N seconds").
|
||||
# route automatic: first current root finding whose rule id is in
|
||||
# `routes` decides; otherwise `default`.
|
||||
# influx the tool asks InfluxDB (online) whether this box's data arrives,
|
||||
# again every `poll_s` until "ok" or `timeout_s`; `outcomes` maps
|
||||
# ok / sensors_missing / silent / never / unavailable -> target.
|
||||
# Targets: "<step id>", "end:<end id>", "guide:<guide id>".
|
||||
# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to).
|
||||
# Text placeholders: {expected_led} {failing_sensors} {top_finding}
|
||||
@@ -33,7 +36,7 @@ start = "led"
|
||||
id = "led"
|
||||
type = "led"
|
||||
text = "Look at the RGB status LED on the box (no need to open it). Which colour is it?"
|
||||
route = { GREEN = "heartbeat", PURPLE = "guide:network", RED = "guide:sensors", BLUE = "guide:rtc_i2c", WHITE = "guide:white", YELLOW = "guide:update", ORANGE = "guide:update", OFF = "guide:power", CYCLING = "guide:reboot_loop" }
|
||||
route = { GREEN = "heartbeat", PURPLE = "guide:network", RED = "guide:sensors", BLUE = "guide:rtc_i2c", WHITE = "guide:white", YELLOW = "guide:update", ORANGE = "guide:update", CYAN = "guide:network", OFF = "guide:power", CYCLING = "guide:reboot_loop" }
|
||||
|
||||
[[guide.step]]
|
||||
id = "heartbeat"
|
||||
@@ -48,14 +51,7 @@ options = [
|
||||
id = "green_route"
|
||||
type = "route"
|
||||
routes = { "rtc.clock_not_running" = "guide:clock", "mic.silent" = "guide:mic", "mic.disabled" = "guide:mic", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network" }
|
||||
default = "end:healthy"
|
||||
|
||||
[[guide.end]]
|
||||
id = "healthy"
|
||||
severity = "ok"
|
||||
title = "Box looks healthy"
|
||||
text = "Green LED, firmware running and no problems in the log."
|
||||
actions = ["If the customer still reports missing data, keep the tool connected for 5 minutes and check that 'Data uploaded to InfluxDB' gets ticked."]
|
||||
default = "guide:influx"
|
||||
|
||||
[[guide.end]]
|
||||
id = "hung"
|
||||
@@ -72,7 +68,8 @@ actions = [
|
||||
id = "network"
|
||||
title = "Purple LED / no Internet"
|
||||
summary = "Pairing in Matter, site Wi-Fi and Internet checks, with live confirmation."
|
||||
recommend = ["led:PURPLE", "matter.not_commissioned", "net.dns_fail", "influx.buffer_fail", "ota.registration_failed"]
|
||||
recommend = ["led:PURPLE", "led:CYAN", "matter.not_commissioned", "net.dns_fail", "influx.buffer_fail", "ota.registration_failed",
|
||||
"status.conn", "status.conn.1", "kota.wifi_down", "kota.no_upload", "kota.weak_wifi", "kota.ota_unregistered"]
|
||||
start = "paired"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -168,6 +165,7 @@ text = "Pairing, Internet time and the first upload all worked."
|
||||
actions = [
|
||||
"The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.",
|
||||
"It should end green.",
|
||||
"Then run the check 'Sensor data in the database' - the first upload may carry only the box status.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
@@ -249,7 +247,7 @@ actions = ["Keep the tool connected a few more minutes and watch the Diagnosis t
|
||||
id = "rtc_i2c"
|
||||
title = "Blue LED - clock / I2C cable"
|
||||
summary = "Re-seat the I2C cable and verify the start-up live."
|
||||
recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors"]
|
||||
recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors", "kota.i2c_stuck", "kota.rtc_read_failed", "status.rtc.1", "status.rtc.2"]
|
||||
start = "explain"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -310,7 +308,8 @@ actions = ["Replace the RTC module, or send the box for repair with the session
|
||||
id = "white"
|
||||
title = "White LED - SD card / reset / setup mode"
|
||||
summary = "Tell apart the three white states and check the SD card."
|
||||
recommend = ["led:WHITE", "sd.card_fail", "sd.mount_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail"]
|
||||
recommend = ["led:WHITE", "sd.card_fail", "sd.mount_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail",
|
||||
"kota.sd_unmounted", "status.sd", "status.portal", "status.reset"]
|
||||
start = "button"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -389,7 +388,9 @@ actions = [
|
||||
id = "sensors"
|
||||
title = "Red LED - sensor or hardware fault"
|
||||
summary = "Find the failing sensor and verify after re-seating its cable."
|
||||
recommend = ["led:RED", "sensor.init_failed", "sensor.selfcheck_failed", "sys.out_of_resources", "ota.selfcheck_failed"]
|
||||
recommend = ["led:RED", "sensor.init_failed", "sensor.selfcheck_failed", "sys.out_of_resources", "ota.selfcheck_failed",
|
||||
"kota.sensor_missing", "status.error", "status.error.1", "status.error.2", "status.error.3", "status.error.4",
|
||||
"status.error.5", "status.error.6", "status.oom"]
|
||||
start = "which"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -470,7 +471,8 @@ actions = ["Save the session log and send it to the firmware team."]
|
||||
id = "reboot_loop"
|
||||
title = "LED keeps changing / box restarts"
|
||||
summary = "Record several start-ups and find the error just before each restart."
|
||||
recommend = ["led:CYCLING", "reboot.loop", "sys.crash", "reboot.unexpected", "reboot.brownout", "reboot.watchdog"]
|
||||
recommend = ["led:CYCLING", "reboot.loop", "sys.crash", "reboot.unexpected", "reboot.brownout", "reboot.watchdog",
|
||||
"reboot.crash", "reboot.status_watchdog", "reboot.status_oom_watchdog"]
|
||||
start = "record"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -606,7 +608,7 @@ actions = [
|
||||
id = "clock"
|
||||
title = "Clock not set"
|
||||
summary = "Why readings are on hold and when the coin cell matters."
|
||||
recommend = ["rtc.clock_not_running", "rtc.lost_after_sync"]
|
||||
recommend = ["rtc.clock_not_running", "rtc.lost_after_sync", "kota.rtc_stopped", "kota.rtc_not_ticking", "kota.time_invalid"]
|
||||
start = "internet"
|
||||
|
||||
[[guide.step]]
|
||||
@@ -733,3 +735,99 @@ severity = "error"
|
||||
title = "Microphone records nothing"
|
||||
text = "Even loud noise next to the box gives no signal, although the switch is on."
|
||||
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
|
||||
|
||||
# ================================================================== influx
|
||||
[[guide]]
|
||||
id = "influx"
|
||||
title = "Sensor data in the database (InfluxDB)"
|
||||
summary = "Checks online that the sensor readings of this box reach InfluxDB, not only its status."
|
||||
recommend = ["always"]
|
||||
start = "check"
|
||||
|
||||
[[guide.step]]
|
||||
id = "check"
|
||||
type = "influx"
|
||||
text = "Checking in InfluxDB that this box's data arrives: device status, microphone and the sensors (AS7341, BME688, SEN66). Keep the box switched on and online. The tool checks again every 30 s for up to 4 minutes (the air sensor needs about 2 minutes after a restart). The laptop needs Internet for this check."
|
||||
timeout_s = 240
|
||||
poll_s = 30
|
||||
outcomes = { ok = "end:data_ok", sensors_missing = "why", silent = "why_silent", never = "why_silent", unavailable = "end:unavailable" }
|
||||
|
||||
[[guide.step]]
|
||||
id = "why_silent"
|
||||
type = "route"
|
||||
routes = { "influx.invalid_value" = "end:invalid_value" }
|
||||
default = "end:silent"
|
||||
|
||||
[[guide.step]]
|
||||
id = "why"
|
||||
type = "route"
|
||||
routes = { "influx.invalid_value" = "end:invalid_value", "kota.rtc_stopped" = "end:sensors_clock", "rtc.clock_not_running" = "end:sensors_clock", "kota.rtc_not_ticking" = "end:sensors_clock", "kota.time_invalid" = "end:sensors_clock", "kota.rtc_read_failed" = "guide:rtc_i2c", "rtc.init_failed" = "guide:rtc_i2c", "sensor.init_failed" = "guide:sensors", "kota.sensor_missing" = "guide:sensors" }
|
||||
default = "end:sensors_missing"
|
||||
|
||||
[[guide.end]]
|
||||
id = "data_ok"
|
||||
severity = "ok"
|
||||
title = "Box is healthy and its sensor data reaches InfluxDB"
|
||||
text = "Status, microphone and all sensor readings of this box arrived in InfluxDB in the last 15 minutes."
|
||||
actions = []
|
||||
|
||||
[[guide.end]]
|
||||
id = "invalid_value"
|
||||
severity = "critical"
|
||||
title = "InfluxDB rejects every upload of this box (invalid value -inf)"
|
||||
text = "The box is online and uploads, but one measurement carries -inf (the microphone records pure silence). InfluxDB refuses the whole upload with HTTP 400, so neither the status nor the sensor data arrive."
|
||||
actions = [
|
||||
"Run the Microphone check: a working microphone (and its switch ON) avoids the -inf value.",
|
||||
"Save the session log and send it to the firmware team: the firmware must skip invalid values.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "sensors_clock"
|
||||
severity = "error"
|
||||
title = "Sensor data is lost: the box clock (RTC) is not running"
|
||||
text = "The box sends its status and microphone level with the Internet time, but the sensor readings are timestamped by the clock module. With the clock stopped they are stamped 1 Jan 2000 and do not show up in InfluxDB."
|
||||
actions = [
|
||||
"Replace the RTC coin cell (inside the case), switch the box off and on, then run this check again.",
|
||||
"If the Diagnosis tab still shows the clock not running with a new cell: replace the RTC module.",
|
||||
"Save the session log for the firmware team.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "sensors_missing"
|
||||
severity = "error"
|
||||
title = "Status arrives, sensor data does not"
|
||||
text = "The box is online and InfluxDB receives its status, but readings of some sensors are missing (the list is in the technician notes of the report)."
|
||||
actions = [
|
||||
"Check the Diagnosis tab for sensor errors; re-seat the cable of the missing sensor (inside the case).",
|
||||
"If the log shows no sensor error: save the session log and send it to the firmware team.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "silent"
|
||||
severity = "warning"
|
||||
title = "Box is not uploading right now"
|
||||
text = "InfluxDB has older data from this box, but nothing from the last 15 minutes."
|
||||
actions = [
|
||||
"Check that the box has Wi-Fi and Internet (Purple LED / no Internet check), then run this check again.",
|
||||
"The technician notes of the report show when each kind of data arrived last - if the sensor data stopped long before the status, treat it like 'Status arrives, sensor data does not'.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "never"
|
||||
severity = "warning"
|
||||
title = "No data from this box in InfluxDB"
|
||||
text = "In the last 30 days InfluxDB received nothing from this box: it was never online, or its SD card has wrong database settings."
|
||||
actions = [
|
||||
"Run the Purple LED / no Internet check.",
|
||||
"If the box is online: re-provision the SD card with the current ENV.TXT (SD card tab).",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "unavailable"
|
||||
severity = "info"
|
||||
title = "Could not check InfluxDB"
|
||||
text = "The tool could not query InfluxDB from this computer (no Internet on the laptop, InfluxDB access not set up, or the box MAC not known yet)."
|
||||
actions = [
|
||||
"Settings > InfluxDB access: enter the read token from the firmware team.",
|
||||
"Or check the box later in the service system (Stan w InfluxDB).",
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Knowledge about the structured "KOTA:" lines of firmware 1.0.16+.
|
||||
|
||||
Firmware source of truth (kota-sensorboard/main):
|
||||
boot_info.cpp "boot fw=.. build=.. hw=.. mac=.. reset=.. mode=.. commissioned=.. last_restart=.."
|
||||
"restart reason=.." (W, printed just before a self-restart)
|
||||
status.cpp "status A -> B [code=N] reason=x", "led color=.. pattern=.. [n=..|period=..ms]",
|
||||
"led previous_boot status=.. code=..", "led restart_flash color=.."
|
||||
i2c.cpp "i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307(missing) .. [bus=stuck]"
|
||||
ds1307.cpp "rtc running=1 time=..", "rtc read_failed err=..", "rtc verify running=1 delta=2s ok=1"
|
||||
sd_card.cpp "sd type=.. size=.. fs=.. free=.. env_keys=A,B buffer_lines=N", "sd mounted=0 [err=..]"
|
||||
main.cpp "matter fabrics=N window=open|closed qr=MT:.. manual=.."
|
||||
microphone.cpp "mic enabled=0 (GPIO3=1, expected 0)"
|
||||
health.cpp "health up=60s status=OK wifi=connected rssi=-61 time_valid=1 sd=ok buffer=0
|
||||
heap=123456 psram=7.8M last_upload=12s|never ota=registered|unregistered|selfcheck"
|
||||
|
||||
Error IDs ("[E-SD-03] ...") come from error_ids.csv, a copy of the
|
||||
firmware's docs/error_ids.csv (the firmware build generates its IDs from it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ERROR_IDS_PATH = Path(__file__).with_name("error_ids.csv")
|
||||
|
||||
# Firmware colour names (status.cpp colors[]) -> engine LED keys.
|
||||
LED_COLOURS = {
|
||||
"green": "GREEN", "cyan": "CYAN", "white": "WHITE", "red": "RED",
|
||||
"blue": "BLUE", "purple": "PURPLE", "yellow": "YELLOW", "orange": "ORANGE",
|
||||
}
|
||||
|
||||
RED_CODES = {1: "BME688", 2: "SEN66", 3: "AS7341", 4: "Microphone", 5: "Flicker sensor", 6: "Self-check"}
|
||||
I2C_DEVICES = {"0x39": "AS7341", "0x68": "DS1307", "0x6B": "SEN66", "0x76": "BME688"}
|
||||
|
||||
# last_restart values written by kota_restart() that are part of normal
|
||||
# operation (button presses, a finished setup, an installed update).
|
||||
INTENTIONAL_RESTARTS = {
|
||||
"ota_update", "button_provisioning_abort", "button_wifi_cleared",
|
||||
"button_provisioning_toggle", "provisioning_saved",
|
||||
"usb_wifi_set", "usb_wifi_clear", # Wi-Fi changed from the technician tool (usb_console)
|
||||
}
|
||||
|
||||
MODULES = {
|
||||
"SD": "SD card", "ENV": "ENV.TXT", "INF": "InfluxDB upload", "OTA": "Firmware update",
|
||||
"WIFI": "Wi-Fi", "PROV": "Wi-Fi setup", "MAIN": "Start-up", "STAT": "Status LED", "SPI": "SPI bus",
|
||||
"RTC": "Clock (RTC)", "BME": "BME688", "SEN": "SEN66", "AS": "AS7341", "MIC": "Microphone",
|
||||
"FLK": "Flicker sensor", "DEV": "Device", "GATH": "Sensor readings",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorId:
|
||||
id: str
|
||||
module: str
|
||||
severity: str # error | warning | retired
|
||||
message: str
|
||||
action: str
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
return MODULES.get(self.module, self.module)
|
||||
|
||||
|
||||
def load_error_ids(path: Path = ERROR_IDS_PATH) -> dict[str, ErrorId]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open(encoding="utf-8", newline="") as fh:
|
||||
return {r["id"]: ErrorId(r["id"], r["module"], r["severity"], r["message"], r["action"])
|
||||
for r in csv.DictReader(fh)}
|
||||
|
||||
|
||||
def led_for_status(status: str, code: int) -> tuple[str, str]:
|
||||
"""(LED key, pattern text) for a status - mirrors Status::pattern_for().
|
||||
Used when the box's own "led" line was not captured (connected mid-run)."""
|
||||
if status == "OK":
|
||||
return "GREEN", "solid"
|
||||
if status == "ERROR":
|
||||
return ("RED", f"{code} pulses") if 1 <= code <= 6 else ("RED", "solid")
|
||||
if status == "OUT_OF_RESOURCES":
|
||||
return "RED", "solid"
|
||||
if status == "UPDATING":
|
||||
return "YELLOW", "blinking"
|
||||
if status == "SELF_CHECK":
|
||||
return "ORANGE", "solid"
|
||||
if status == "CONNECTION_ERROR":
|
||||
return ("PURPLE", "blinking") if code == 1 else ("PURPLE", "solid")
|
||||
if status == "RTC_ERROR":
|
||||
return "BLUE", "2 pulses" if code == 2 else "1 pulse"
|
||||
if status == "SD_ERROR":
|
||||
return "WHITE", "fast blinking"
|
||||
if status == "RESETING":
|
||||
return "WHITE", "solid"
|
||||
if status == "HTTP_SERVER":
|
||||
return "WHITE", "breathing"
|
||||
return "UNKNOWN", ""
|
||||
|
||||
|
||||
def pattern_text(fields: dict[str, str]) -> str:
|
||||
"""'led color=red pattern=pulses n=2' -> '2 pulses'."""
|
||||
p = fields.get("pattern", "")
|
||||
if p == "pulses":
|
||||
n = fields.get("n", "?")
|
||||
return f"{n} pulse" if n == "1" else f"{n} pulses"
|
||||
if p == "blink":
|
||||
period = fields.get("period", "").removesuffix("ms")
|
||||
fast = period.isdigit() and int(period) <= 500
|
||||
return ("fast blinking" if fast else "blinking") + (f" ({period} ms)" if period else "")
|
||||
if p == "breathe":
|
||||
return "breathing"
|
||||
return p # solid / off
|
||||
|
||||
|
||||
def led_meaning(colour: str, pattern: str) -> str:
|
||||
"""Technician meaning of a colour + pattern (firmware diagnostics doc)."""
|
||||
if colour == "GREEN":
|
||||
return "OK"
|
||||
if colour == "CYAN":
|
||||
return "phone connected for pairing" if "fast" in pattern else "waiting to be paired in Matter"
|
||||
if colour == "WHITE":
|
||||
if "fast" in pattern:
|
||||
return "SD card or ENV.TXT error"
|
||||
if "breath" in pattern:
|
||||
return "Wi-Fi setup portal"
|
||||
return "factory reset"
|
||||
if colour == "RED":
|
||||
if "pulse" in pattern:
|
||||
n = int(pattern.split()[0]) if pattern.split()[0].isdigit() else 0
|
||||
return f"{RED_CODES.get(n, 'sensor')} failed"
|
||||
return "internal error / out of memory"
|
||||
if colour == "BLUE":
|
||||
return "I2C bus stuck (sensor cable)" if pattern.startswith("2") else "clock (RTC) not responding"
|
||||
if colour == "PURPLE":
|
||||
return "server rejected the box (check ENV.TXT credentials)" if "blink" in pattern else "no network"
|
||||
if colour == "YELLOW":
|
||||
return "downloading a firmware update"
|
||||
if colour == "ORANGE":
|
||||
return "self-check after an update"
|
||||
return ""
|
||||
|
||||
|
||||
# Finding for the current firmware status (Status::set_status). key suffix ->
|
||||
# (severity, title, meaning, actions, explained_by). The status finding is
|
||||
# grouped under a more specific finding when one explains it.
|
||||
_SENSOR_ROOTS = ("sensor.init_failed", "sensor.selfcheck_failed", "kota.i2c_stuck", "kota.sensor_missing",
|
||||
"sen66.not_ready", "bme688.read_fail", "as7341.read_fail", "mic.read_fail",
|
||||
"ota.selfcheck_failed")
|
||||
_NET_ROOTS = ("matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal", "kota.wifi_down",
|
||||
"net.dns_fail", "net.tls_fail")
|
||||
|
||||
|
||||
def status_finding(status: str, code: int) -> tuple[str, str, str, str, tuple[str, ...], tuple[str, ...]] | None:
|
||||
"""(key, severity, title, meaning, actions, explained_by) or None for healthy states."""
|
||||
if status == "ERROR":
|
||||
if 1 <= code <= 5:
|
||||
s = RED_CODES[code]
|
||||
return (f"status.error.{code}", "critical", f"{s} fault (LED red, {code} pulses)",
|
||||
f"The firmware reports the {s} as failed.",
|
||||
(f"Open the case and check the {s} connector/cable.",
|
||||
"If several sensors fail: check the shared I2C cable.",
|
||||
"The box restarts itself after 150 s in this state."), _SENSOR_ROOTS)
|
||||
if code == 6:
|
||||
return ("status.error.6", "error", "Post-update self-check failed (LED red, 6 pulses)",
|
||||
"The new firmware failed its self-check and rolls back.",
|
||||
("Check the sensor named in the log (inside the case), then power-cycle.",), _SENSOR_ROOTS)
|
||||
return ("status.error", "error", "Internal error (LED red)",
|
||||
"The firmware is in its general error state.",
|
||||
("Save the session log and send it to the firmware team.",
|
||||
"The box restarts itself after 150 s in this state."), _SENSOR_ROOTS)
|
||||
if status == "OUT_OF_RESOURCES":
|
||||
return ("status.oom", "error", "Firmware ran out of memory (LED red)",
|
||||
"The firmware could not allocate memory; it restarts itself after 30 s.",
|
||||
("Power-cycle. If it repeats, save the session log for the firmware team.",),
|
||||
("sys.out_of_resources",))
|
||||
if status == "CONNECTION_ERROR":
|
||||
if code == 1:
|
||||
return ("status.conn.1", "critical", "Server rejected the box (LED purple, blinking)",
|
||||
"The network works but the server refused the box (HTTP 401/403/404): "
|
||||
"INFLUX_URL / INFLUX_TOKEN in ENV.TXT are wrong or revoked.",
|
||||
("Re-provision the SD card with the current ENV.TXT (SD card is inside the case).",),
|
||||
("influx.auth", "influx.not_found"))
|
||||
return ("status.conn", "error", "No connection to the servers (LED purple)",
|
||||
"Uploads or the update check fail: no Wi-Fi, no Internet or the server is unreachable.",
|
||||
("Check the box is paired/connected to Wi-Fi and the network has Internet.",),
|
||||
_NET_ROOTS + ("influx.buffer_fail", "ota.check_failed", "influx.server_error"))
|
||||
if status == "RTC_ERROR":
|
||||
if code == 2:
|
||||
return ("status.rtc.2", "critical", "I2C bus stuck (LED blue, 2 pulses)",
|
||||
"SDA or SCL is held low: a shorted or broken sensor cable, or a faulty sensor.",
|
||||
("Open the case and check the I2C cable between the boards.",
|
||||
"Unplug sensors one by one to find the one holding the bus."), ("kota.i2c_stuck",))
|
||||
return ("status.rtc.1", "critical", "Clock chip (RTC) not responding (LED blue, 1 pulse)",
|
||||
"The DS1307 clock does not answer on the I2C bus.",
|
||||
("Open the case and check the I2C cable/connector between the boards.",
|
||||
"If the cable is fine: the RTC module is faulty."),
|
||||
("rtc.init_failed", "kota.rtc_read_failed", "kota.sensor_missing"))
|
||||
if status == "SD_ERROR":
|
||||
return ("status.sd", "critical", "SD card or ENV.TXT error (LED white, fast blinking)",
|
||||
"The SD card cannot be read or ENV.TXT is missing settings.",
|
||||
("Open the case, re-seat the SD card or re-provision it with the tool.",),
|
||||
("kota.sd_unmounted", "influx.env_missing", "sd.card_fail", "sd.mount_fail",
|
||||
"sd.env_open_fail", "influx.init_fail"))
|
||||
if status == "RESETING":
|
||||
return ("status.reset", "warning", "Factory reset in progress (LED white, solid)",
|
||||
"The Matter button was held: the box forgets its Matter network.",
|
||||
("Pair the box again in the Matter app afterwards.",), ("sys.factory_reset",))
|
||||
if status == "HTTP_SERVER":
|
||||
return ("status.portal", "warning", "Box is in Wi-Fi setup mode (LED white, breathing)",
|
||||
"The box hosts its own Wi-Fi network for configuration.",
|
||||
("Connect a phone to the box's network and open http://sensorbox.local/,",
|
||||
"or short-press the Matter button to leave setup mode."), ("wifi.setup_portal",))
|
||||
return None # OK, UPDATING, SELF_CHECK: covered by milestones / OTA rules
|
||||
|
||||
|
||||
# last_restart -> (severity, title, meaning, actions); None = not a fault.
|
||||
def restart_finding(reason: str) -> tuple[str, str, str, tuple[str, ...]] | None:
|
||||
if reason in INTENTIONAL_RESTARTS or reason in ("none", "", "-"):
|
||||
return None
|
||||
if reason == "status_watchdog":
|
||||
return ("warning", "Box restarted itself after 150 s in the red error state",
|
||||
"By design the firmware restarts when it stays in the error state (red) for 150 s.",
|
||||
("Fix the grouped cause; the restarts stop by themselves.",))
|
||||
if reason == "status_oom_watchdog":
|
||||
return ("error", "Box restarted itself because it ran out of memory",
|
||||
"The firmware was out of memory for 30 s and restarted.",
|
||||
("Save the session log for the firmware team.",))
|
||||
if reason == "provisioning_failed":
|
||||
return ("warning", "Wi-Fi setup portal failed to start",
|
||||
"The setup portal could not start; the box cleared setup mode and restarted.",
|
||||
("Retry Wi-Fi setup (short button press); restart the device if it persists.",))
|
||||
if reason == "panic":
|
||||
return ("critical", "Firmware crashed (panic) and restarted",
|
||||
"The firmware hit a fatal error.",
|
||||
("Press Reset & capture to record the crash; save the session log for the firmware team.",))
|
||||
if reason in ("task_wdt", "int_wdt", "wdt"):
|
||||
return ("error", "Firmware froze and was restarted by the watchdog",
|
||||
f"The firmware stopped responding ({reason}); the watchdog restarted it.",
|
||||
("Save the session log for the firmware team.",))
|
||||
if reason == "brownout":
|
||||
return ("error", "Power supply voltage dropped (brown-out)",
|
||||
"The supply voltage fell too low and the chip reset itself.",
|
||||
("Check the power supply, cable and connectors.",))
|
||||
if reason == "unknown_sw":
|
||||
return ("warning", "Unexpected software restart",
|
||||
"The firmware restarted without recording a reason.",
|
||||
("Watch whether it repeats; save the session log for the firmware team.",))
|
||||
return ("warning", f"Box restarted itself ({reason})",
|
||||
f"The firmware recorded the restart reason '{reason}'.",
|
||||
("Save the session log for the firmware team if it repeats.",))
|
||||
|
||||
|
||||
def parse_size_gb(value: str) -> float | None:
|
||||
"""'7.3GB' -> 7.3 (sd_card.cpp format_gb)."""
|
||||
v = value.upper().removesuffix("GB")
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_seconds(value: str) -> int | None:
|
||||
v = value.removesuffix("s")
|
||||
return int(v) if v.lstrip("-").isdigit() else None
|
||||
@@ -8,6 +8,9 @@
|
||||
# Fields:
|
||||
# id unique, "<category>.<name>"
|
||||
# pattern Python regex (search). Named groups can be used in `title`.
|
||||
# Matched against the line with its error ID ("[E-SD-03] ")
|
||||
# removed. Rules without a pattern ("kota.*") are raised by
|
||||
# the engine from the structured KOTA: lines (diag/kota.py).
|
||||
# severity critical | error | warning | info | ignore
|
||||
# ignore = never shown (benign ESP_LOGE lines, LED-only rules)
|
||||
# title short technician-facing headline
|
||||
@@ -121,7 +124,7 @@ led_from = "PURPLE"
|
||||
|
||||
[[rule]]
|
||||
id = "benign.mac"
|
||||
pattern = 'ID: Device MAC address'
|
||||
pattern = 'Device MAC address'
|
||||
severity = "ignore"
|
||||
|
||||
[[rule]]
|
||||
@@ -134,6 +137,14 @@ id = "benign.zcl"
|
||||
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
|
||||
severity = "ignore"
|
||||
|
||||
[[rule]]
|
||||
id = "benign.wifi_start"
|
||||
# Harmless Wi-Fi noise seen on every 1.0.16 boot: Matter asks for a connect
|
||||
# while the station is already connecting, and an unpaired box polls a
|
||||
# network it does not have yet (matter.not_commissioned covers that case).
|
||||
pattern = 'wifi:sta is connecting, return error|esp_wifi_connect\(\) failed: ESP_ERR_WIFI_CONN|Failed to get configured network when updating network status'
|
||||
severity = "ignore"
|
||||
|
||||
[[rule]]
|
||||
id = "benign.connect_chain"
|
||||
# Transport-level echo of a failure that a more specific rule reports.
|
||||
@@ -222,7 +233,7 @@ id = "influx.buffer_fail"
|
||||
pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer'
|
||||
severity = "error"
|
||||
title = "Measurements cannot be uploaded to InfluxDB"
|
||||
meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple and the box restarts itself after about 2.5 minutes in this state."
|
||||
meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple (firmware v1.0 also restarts itself after about 2.5 minutes in this state)."
|
||||
actions = [
|
||||
"Fix the network problem first (see the finding this is grouped under).",
|
||||
"Buffered data is uploaded automatically once the connection works.",
|
||||
@@ -269,6 +280,20 @@ severity = "warning"
|
||||
title = "InfluxDB refused some measurements (HTTP 400)"
|
||||
meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)."
|
||||
actions = ["Save the session log and send it to the firmware team."]
|
||||
explained_by = ["influx.invalid_value"]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.invalid_value"
|
||||
# Line protocol printed by SENSOR_GATHERER / INFLUX: "-inf"/"nan" are not valid
|
||||
# numbers, and one bad line makes InfluxDB reject the whole upload.
|
||||
pattern = '^(?:.*format: )?(?P<measurement>[a-z0-9_]+),device=\S+ \S*=-?(?:inf|nan)\b'
|
||||
severity = "critical"
|
||||
title = "Box sends an invalid value (-inf/nan) - InfluxDB rejects all its data"
|
||||
meaning = "A measurement contains -inf or nan (the microphone: pure silence gives -inf dB). InfluxDB refuses the whole upload with HTTP 400, so status and sensor data from this box are lost too. Firmware bug; a silent microphone triggers it."
|
||||
actions = [
|
||||
"Check the microphone (Microphone check): a working microphone avoids the -inf value.",
|
||||
"Save the session log and send it to the firmware team: invalid values must be skipped, not uploaded.",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "influx.env_missing"
|
||||
@@ -340,6 +365,7 @@ actions = [
|
||||
"Open the case and check the I2C cable/connector between the boards.",
|
||||
"If the cable is fine: the RTC module is faulty.",
|
||||
]
|
||||
explained_by = ["kota.i2c_stuck"]
|
||||
led = "BLUE"
|
||||
|
||||
[[rule]]
|
||||
@@ -353,6 +379,7 @@ actions = [
|
||||
"Give the box Internet access: the clock is set automatically on the first connection.",
|
||||
"If this comes back after every power-off: replace the RTC coin cell (inside the case).",
|
||||
]
|
||||
explained_by = ["kota.rtc_stopped"]
|
||||
|
||||
[[rule]]
|
||||
id = "i2c.errors"
|
||||
@@ -362,6 +389,7 @@ min_count = 2
|
||||
title = "I2C bus communication errors"
|
||||
meaning = "Sensors are not answering reliably on the I2C bus: loose connector, damaged cable, or a failing sensor."
|
||||
actions = ["Open the case and check the I2C cable/connectors between the boards."]
|
||||
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
|
||||
|
||||
# ------------------------------------------------------------------ sensors
|
||||
|
||||
@@ -376,6 +404,7 @@ actions = [
|
||||
"Open the case and check the {sensor} connector/cable.",
|
||||
"If several sensors fail in turn: check the shared I2C cable.",
|
||||
]
|
||||
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
|
||||
led = "RED"
|
||||
|
||||
[[rule]]
|
||||
@@ -570,3 +599,148 @@ title = "A sensor returned no data"
|
||||
meaning = "One of the sensors skipped its reading; the preceding log lines name the sensor."
|
||||
actions = ["Check the log lines just before this message for the sensor name."]
|
||||
explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"]
|
||||
|
||||
# ------------------------------------------------ structured KOTA: lines
|
||||
# Firmware 1.0.16+ reports its state as "KOTA: <record> key=value ..." lines.
|
||||
# These rules have no pattern: the engine raises them from the parsed fields
|
||||
# (see diag/kota.py for the line formats). Named values in {braces} come
|
||||
# from the line's fields.
|
||||
|
||||
[[rule]]
|
||||
id = "kota.i2c_stuck"
|
||||
severity = "critical"
|
||||
title = "I2C bus stuck (SDA={sda}, SCL={scl})"
|
||||
meaning = "A data line of the sensor bus is held low before any sensor is used: a shorted or broken sensor cable, or a faulty sensor. LED blue, 2 pulses."
|
||||
actions = [
|
||||
"Open the case and check the I2C cable between the boards.",
|
||||
"Unplug the sensors one by one to find the one holding the bus.",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.sensor_missing"
|
||||
split_by = "sensor"
|
||||
severity = "error"
|
||||
title = "{sensor} does not answer on the I2C bus"
|
||||
meaning = "The start-up scan did not find the {sensor} at address {addr}. The other sensors answer, so the bus itself works: the {sensor} cable, connector or the sensor is faulty."
|
||||
actions = [
|
||||
"Open the case and re-seat the {sensor} cable/connector.",
|
||||
"If it is still missing after a restart: replace the {sensor}.",
|
||||
]
|
||||
explained_by = ["kota.i2c_stuck"]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.rtc_read_failed"
|
||||
severity = "critical"
|
||||
title = "Clock chip (RTC) cannot be read ({err})"
|
||||
meaning = "The DS1307 clock does not answer. It is on the shared I2C bus, so a loose cable also causes this."
|
||||
actions = [
|
||||
"Open the case and check the I2C cable/connector between the boards.",
|
||||
"If the cable is fine: replace the RTC module.",
|
||||
]
|
||||
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.rtc_stopped"
|
||||
severity = "warning"
|
||||
title = "Clock chip was stopped at start-up (time {time})"
|
||||
meaning = "The RTC answers but its clock was not running - typical after its coin cell was removed or is empty. It is set again from the Internet once the box is online; until then readings are held."
|
||||
actions = [
|
||||
"Give the box Internet access: the clock is set automatically.",
|
||||
"If this comes back after every power-off: replace the RTC coin cell (inside the case).",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.rtc_not_ticking"
|
||||
severity = "error"
|
||||
title = "Clock chip does not keep time"
|
||||
meaning = "The clock was set from the Internet, but 2 s later it had not advanced correctly (running={running}, delta={delta}). The RTC module or its crystal is faulty."
|
||||
actions = ["Replace the RTC module (inside the case)."]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.sd_unmounted"
|
||||
severity = "critical"
|
||||
title = "SD card not mounted ({err})"
|
||||
meaning = "The firmware cannot use the SD card: missing, not seated, not FAT32 or damaged. Without it the box cannot read ENV.TXT. LED white, fast blinking."
|
||||
actions = [
|
||||
"Open the case and re-seat the SD card.",
|
||||
"Re-provision the card with the tool (FAT32, MBR) or try a freshly provisioned card.",
|
||||
]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.sd_not_fat32"
|
||||
severity = "warning"
|
||||
title = "SD card file system is {fs}, not FAT32"
|
||||
meaning = "The card works, but the provisioning standard is FAT32."
|
||||
actions = ["Re-provision the card with the tool when convenient."]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.sd_full"
|
||||
severity = "warning"
|
||||
title = "SD card is almost full ({free} free)"
|
||||
meaning = "Offline readings may soon not be stored."
|
||||
actions = ["Check the buffered readings upload (Internet access), or replace the card."]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.buffer_backlog"
|
||||
severity = "warning"
|
||||
title = "{lines} readings are waiting on the SD card to be uploaded"
|
||||
meaning = "The box stored readings while it could not upload them. They are sent automatically once the connection works."
|
||||
actions = ["Fix the connection problem if there is one; watch the health line: the buffer should shrink."]
|
||||
explained_by = ["kota.wifi_down", "matter.not_commissioned", "status.conn", "status.conn.1", "net.dns_fail", "influx.auth"]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.wifi_down"
|
||||
severity = "error"
|
||||
min_count = 2
|
||||
title = "Box has no Wi-Fi connection"
|
||||
meaning = "The periodic health report says Wi-Fi is disconnected, so the box cannot upload data or receive updates."
|
||||
actions = [
|
||||
"Check the site Wi-Fi is on, 2.4 GHz and in range.",
|
||||
"Matter boxes: check the box is still paired in the Matter app. Wi-Fi-from-SD boxes: check SSID/PASSWORD in ENV.TXT.",
|
||||
]
|
||||
explained_by = ["matter.not_commissioned", "wifi.setup_portal", "wifi.disconnected"]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.weak_wifi"
|
||||
severity = "warning"
|
||||
title = "Weak Wi-Fi signal ({rssi} dBm)"
|
||||
meaning = "The connection works but the signal is weak; uploads may fail intermittently. Below about -75 dBm is unreliable."
|
||||
actions = ["Move the box or the access point closer, or add a repeater."]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.time_invalid"
|
||||
severity = "warning"
|
||||
title = "Box has no valid time - readings are on hold"
|
||||
meaning = "Readings are only stored with a valid timestamp. The time comes from the RTC or from the Internet."
|
||||
actions = [
|
||||
"Give the box Internet access so it can set its clock.",
|
||||
"If it is online and this persists: check the RTC findings.",
|
||||
]
|
||||
explained_by = ["kota.wifi_down", "matter.not_commissioned", "kota.rtc_not_ticking", "kota.rtc_read_failed", "kota.rtc_stopped", "rtc.clock_not_running", "net.dns_fail"]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.no_upload"
|
||||
severity = "error"
|
||||
title = "Connected to Wi-Fi, but no data uploaded yet (5+ min)"
|
||||
meaning = "The box has Wi-Fi but no upload to InfluxDB has succeeded yet."
|
||||
actions = [
|
||||
"Check the network has Internet (open a website on a phone on the same Wi-Fi).",
|
||||
"Check INFLUX_URL / INFLUX_TOKEN in ENV.TXT (re-provision the SD card).",
|
||||
]
|
||||
explained_by = ["influx.invalid_value", "kota.time_invalid", "influx.auth", "influx.not_found", "status.conn.1", "net.dns_fail", "net.tls_fail", "influx.server_error"]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.low_heap"
|
||||
severity = "warning"
|
||||
title = "Firmware memory is low ({heap} bytes free)"
|
||||
meaning = "Internal memory is running low; the firmware may fail to start tasks and restart."
|
||||
actions = ["Save the session log for the firmware team."]
|
||||
|
||||
[[rule]]
|
||||
id = "kota.ota_unregistered"
|
||||
severity = "warning"
|
||||
min_count = 3
|
||||
title = "Online, but not registered for firmware updates"
|
||||
meaning = "The box has Wi-Fi but is still not registered with the update server, so it will not receive new firmware."
|
||||
actions = ["Check the network allows HTTPS to bin.kota-app.com; registration is retried automatically."]
|
||||
explained_by = ["ota.registration_failed", "net.dns_fail", "net.tls_fail"]
|
||||
@@ -1,14 +1,16 @@
|
||||
"""Firmware images: validation, integrity and layout.
|
||||
"""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 origins:
|
||||
* factory - bundled with the tool (firmware/firmware.toml), SHA-256 locked;
|
||||
* developer - a newer build the firmware team sends to technicians,
|
||||
imported into a per-user library (~/KotaTechnicianTool/firmware).
|
||||
Two kinds:
|
||||
* merged - bootloader at 0x0 + partition table + app (like new_system.bin);
|
||||
* 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 the factory image.
|
||||
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
|
||||
@@ -19,17 +21,11 @@ from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import struct
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware"
|
||||
MANIFEST = FIRMWARE_DIR / "firmware.toml"
|
||||
LIBRARY_DIR = Path.home() / "KotaTechnicianTool" / "firmware"
|
||||
LIBRARY_INDEX = "library.json"
|
||||
|
||||
PT_OFFSET = 0x8000
|
||||
PT_SIZE = 0xC00
|
||||
@@ -58,7 +54,7 @@ class Partition:
|
||||
class AppInfo:
|
||||
project: str
|
||||
version: str
|
||||
built: str # "Jun 29 2026 11:03:32" as in the app descriptor
|
||||
built: str # "Sep 28 2026 15:07:57" as in the app descriptor
|
||||
idf: str
|
||||
elf_sha: str # hex
|
||||
|
||||
@@ -71,13 +67,16 @@ class AppInfo:
|
||||
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
|
||||
name: str
|
||||
notes: str
|
||||
origin: str # "factory" | "developer"
|
||||
kind: str # "merged" | "app"
|
||||
sha256: str
|
||||
flash_size: str
|
||||
@@ -86,16 +85,15 @@ class FirmwareImage:
|
||||
app_offset: int # where the app starts inside the file
|
||||
app_length: int # exact app image length (from its structure)
|
||||
app: AppInfo
|
||||
built_by: str = "" # who compiled a developer build (shown in the UI)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.path.name
|
||||
|
||||
@property
|
||||
def data(self) -> bytes:
|
||||
return self.path.read_bytes()
|
||||
|
||||
@property
|
||||
def is_developer(self) -> bool:
|
||||
return self.origin == "developer"
|
||||
|
||||
def partition(self, label: str) -> Partition:
|
||||
for p in self.partitions:
|
||||
if p.label == label:
|
||||
@@ -110,11 +108,9 @@ class FirmwareImage:
|
||||
return b"\xff" * self.partition("otadata").size
|
||||
|
||||
def label(self) -> str:
|
||||
if self.origin == "factory":
|
||||
tag = "factory"
|
||||
else:
|
||||
tag = "developer build" + (f" - built by {self.built_by}" if self.built_by else "")
|
||||
return f"{self.app.version} built {self.app.built} [{self.app.elf_sha[:9]}] - {tag}"
|
||||
"""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
|
||||
@@ -184,10 +180,10 @@ def _app_info(data: bytes, offset: int) -> AppInfo:
|
||||
return info
|
||||
|
||||
|
||||
def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "factory",
|
||||
reference: FirmwareImage | None = None, built_by: str = "") -> FirmwareImage:
|
||||
|
||||
def inspect_image(path: Path, reference: FirmwareImage | None = None) -> FirmwareImage:
|
||||
"""Validate a merged or app-only image. App-only images need a `reference`
|
||||
(the factory image) that provides the flash layout."""
|
||||
(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
|
||||
@@ -201,7 +197,8 @@ def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "fa
|
||||
kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?")
|
||||
elif data[:1] == bytes([IMAGE_MAGIC]):
|
||||
if reference is None:
|
||||
raise ImageError("app-only image needs the factory image as layout reference")
|
||||
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")
|
||||
@@ -211,93 +208,53 @@ def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "fa
|
||||
if length > ota0.size:
|
||||
raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)")
|
||||
app = _app_info(data, app_off)
|
||||
return FirmwareImage(path, name or path.name, notes, origin, kind, hashlib.sha256(data).hexdigest(),
|
||||
flash_size, parts, table, app_off, length, app, built_by)
|
||||
return FirmwareImage(path, kind, hashlib.sha256(data).hexdigest(),
|
||||
flash_size, parts, table, app_off, length, app)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- catalogue
|
||||
|
||||
def bundled_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
||||
"""Images shipped with the tool whose file exists and matches its SHA-256.
|
||||
Factory images first; bundled developer builds keep their origin/built_by."""
|
||||
if not manifest.exists():
|
||||
return []
|
||||
out = []
|
||||
for e in tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", []):
|
||||
path = manifest.parent / e["file"]
|
||||
if not path.exists():
|
||||
continue
|
||||
origin = e.get("origin", "factory")
|
||||
if origin not in ("factory", "developer"):
|
||||
raise ImageError(f"{path.name}: unknown origin {origin!r} in the manifest")
|
||||
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), origin, built_by=e.get("built_by", ""))
|
||||
if img.sha256 != e["sha256"].lower():
|
||||
raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt")
|
||||
out.append(img)
|
||||
return sorted(out, key=lambda i: i.origin != "factory")
|
||||
def 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 factory_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
|
||||
"""Only the official factory image(s)."""
|
||||
return [i for i in bundled_images(manifest) if i.origin == "factory"]
|
||||
|
||||
|
||||
class FirmwareLibrary:
|
||||
"""Developer builds imported by the technician (persist across tool versions)."""
|
||||
|
||||
def __init__(self, root: Path = LIBRARY_DIR):
|
||||
self.root = root
|
||||
|
||||
def _index(self) -> list[dict]:
|
||||
f = self.root / LIBRARY_INDEX
|
||||
return json.loads(f.read_text(encoding="utf-8")) if f.exists() else []
|
||||
|
||||
def _save(self, entries: list[dict]) -> None:
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
(self.root / LIBRARY_INDEX).write_text(json.dumps(entries, indent=2), encoding="utf-8")
|
||||
|
||||
def images(self, reference: FirmwareImage | None) -> tuple[list[FirmwareImage], list[str]]:
|
||||
"""(valid images, problems) - a changed or broken file is reported, not offered."""
|
||||
imgs, problems = [], []
|
||||
for e in self._index():
|
||||
path = self.root / e["file"]
|
||||
try:
|
||||
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "developer", reference,
|
||||
e.get("built_by", ""))
|
||||
if img.sha256 != e["sha256"]:
|
||||
raise ImageError("file changed since it was imported")
|
||||
imgs.append(img)
|
||||
except (OSError, ImageError) as exc:
|
||||
problems.append(f"{e.get('original', e['file'])}: {exc}")
|
||||
return imgs, problems
|
||||
|
||||
def import_file(self, src: Path, reference: FirmwareImage | None, notes: str = "",
|
||||
built_by: str = "") -> FirmwareImage:
|
||||
img = inspect_image(src, origin="developer", reference=reference) # validate before copying
|
||||
entries = self._index()
|
||||
existing = next((e for e in entries if e["sha256"] == img.sha256), None)
|
||||
if existing:
|
||||
return inspect_image(self.root / existing["file"], existing["name"], existing["notes"],
|
||||
"developer", reference, existing.get("built_by", ""))
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
fname = f"{img.app.elf_sha[:9]}_{img.sha256[:8]}.bin"
|
||||
shutil.copyfile(src, self.root / fname)
|
||||
built_by = built_by.strip()
|
||||
name = f"Developer build {img.app.version} ({img.app.built})" + (f", built by {built_by}" if built_by else "")
|
||||
entries.append({"file": fname, "sha256": img.sha256, "name": name, "original": src.name,
|
||||
"imported": dt.datetime.now().isoformat(timespec="seconds"), "notes": notes,
|
||||
"built_by": built_by})
|
||||
self._save(entries)
|
||||
return inspect_image(self.root / fname, name, notes, "developer", reference, built_by)
|
||||
|
||||
def remove(self, sha256: str) -> None:
|
||||
entries = self._index()
|
||||
keep = [e for e in entries if e["sha256"] != sha256]
|
||||
for e in entries:
|
||||
if e["sha256"] == sha256:
|
||||
(self.root / e["file"]).unlink(missing_ok=True)
|
||||
self._save(keep)
|
||||
|
||||
|
||||
# Backwards-compatible name used by earlier callers/tests.
|
||||
official_images = factory_images
|
||||
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)
|
||||
@@ -26,7 +26,7 @@ HINTS = [
|
||||
"The box did not enter flashing mode by itself. Open the case, hold the BOOT button, press and release RESET, "
|
||||
"release BOOT, then press Start again."),
|
||||
(re.compile(r"device reports readiness to read but returned no data|The port disappeared|device disconnected", re.I),
|
||||
"The USB connection dropped. Use a short data-capable USB cable directly on the laptop (no hub) and try again."),
|
||||
"The USB connection dropped. Use a data-capable cable with a USB 2.0 type-A plug on the way to the laptop (not USB-C only) and try again."),
|
||||
(re.compile(r"Invalid head of packet|timed out", re.I),
|
||||
"Communication errors. Try another USB cable or port."),
|
||||
]
|
||||
|
||||
@@ -35,8 +35,16 @@ class SetupPayload:
|
||||
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
|
||||
@@ -48,6 +56,8 @@ class SetupPayload:
|
||||
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)
|
||||
@@ -88,9 +98,16 @@ def verhoeff_check(digits: str) -> int:
|
||||
return _INV[c]
|
||||
|
||||
|
||||
def payload_from_log(lines: list[str]) -> SetupPayload:
|
||||
"""Use the box's advertised VID/PID/discriminator if seen; defaults otherwise."""
|
||||
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):
|
||||
return SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True)
|
||||
return SetupPayload()
|
||||
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
|
||||
@@ -243,8 +243,9 @@ def check_card(mount: str, filesystem: str = "") -> CardReport:
|
||||
"""What the box would find on this card (values masked in the UI)."""
|
||||
root = Path(mount)
|
||||
problems: list[str] = []
|
||||
fs = filesystem.upper()
|
||||
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"):
|
||||
# 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
|
||||
|
||||
@@ -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)
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -50,6 +51,7 @@ class SerialManager:
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
self._target: _Target | None = None
|
||||
self._open_error = ""
|
||||
self.state = State.DISCONNECTED
|
||||
|
||||
# ---- public API (any thread) -------------------------------------
|
||||
@@ -88,19 +90,35 @@ class SerialManager:
|
||||
info = find_port(self._target.serial_number, self._target.device)
|
||||
if info is None:
|
||||
return None
|
||||
posix = sys.platform != "win32"
|
||||
# serial_for_url also accepts test URLs such as "loop://".
|
||||
# exclusive: a second tool window must not read the same port (on
|
||||
# macOS/Linux both would get half of the lines). COM ports on Windows
|
||||
# are always exclusive.
|
||||
ser = serial.serial_for_url(
|
||||
info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True
|
||||
info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True,
|
||||
**({"exclusive": True} if posix else {}),
|
||||
)
|
||||
# Deassert both lines before open. On USB-Serial-JTAG, RTS=1/DTR=0
|
||||
# holds the chip in reset and DTR=1 during reset selects download
|
||||
# mode; pyserial would otherwise assert both on open (Windows).
|
||||
ser.dtr = False
|
||||
# On USB-Serial-JTAG, RTS=1/DTR=0 holds the chip in reset and DTR=1
|
||||
# during reset selects download mode.
|
||||
# Windows: pyserial applies both lines atomically at open, so
|
||||
# deassert both beforehand (it would otherwise assert both).
|
||||
# macOS/Linux: the OS asserts both lines on open and pyserial then
|
||||
# writes DTR before RTS, passing through RTS=1/DTR=0 and resetting
|
||||
# the box on every connect. Drop RTS first, then DTR.
|
||||
if not posix:
|
||||
ser.dtr = False
|
||||
ser.rts = False
|
||||
try:
|
||||
ser.open()
|
||||
except (serial.SerialException, OSError):
|
||||
if posix:
|
||||
ser.dtr = False
|
||||
except (serial.SerialException, OSError) as exc:
|
||||
busy = "exclusive" in str(exc).lower() or "busy" in str(exc).lower() or "access" in str(exc).lower()
|
||||
self._open_error = ("The port is used by another program (another Technician Tool window?)"
|
||||
if busy else "")
|
||||
return None
|
||||
self._open_error = ""
|
||||
self._target.device = info.device # COM number / node may change
|
||||
return ser
|
||||
|
||||
@@ -134,7 +152,7 @@ class SerialManager:
|
||||
ser = self._open()
|
||||
if ser is None:
|
||||
if not announced_wait:
|
||||
self._set_state(State.WAITING, "Plug in / power on the sensorbox")
|
||||
self._set_state(State.WAITING, self._open_error or "Plug in / power on the sensorbox")
|
||||
announced_wait = True
|
||||
self._drain_cmds(None)
|
||||
time.sleep(REOPEN_POLL_S)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Does the box's data reach InfluxDB? (read-only query, InfluxDB 2.x)
|
||||
|
||||
The box writes to the "sensorbox" bucket with the tag device=<MAC without
|
||||
separators>. device_status and microphone use the system time, the sensor
|
||||
measurements (AS7341, BME688, SEN66) are timestamped from the box's RTC -
|
||||
so a box can report its status while none of its sensor data arrives
|
||||
(e.g. a stopped RTC stamps readings with 1 Jan 2000).
|
||||
|
||||
The token must be a read token for the bucket. It comes from the
|
||||
KOTA_INFLUX_TOKEN environment variable or the tool settings and is never
|
||||
written to the session log or the service report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import datetime as dt
|
||||
import io
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
DEFAULT_URL = "https://influx.kota-app.com"
|
||||
DEFAULT_ORG = "kota"
|
||||
DEFAULT_BUCKET = "sensorbox"
|
||||
LOOKBACK = "-30d"
|
||||
FRESH_MIN = 15 # like SEEN_OK_MIN in the service system
|
||||
FUTURE_TOLERANCE_MIN = 10 # newer than now + this: the box clock is ahead
|
||||
SINCE_SLACK_S = 60 # clock difference box / laptop when counting "since the test started"
|
||||
TIMEOUT_S = 20
|
||||
|
||||
# Sensor groups that every box must upload: name -> measurement prefixes.
|
||||
SENSOR_GROUPS = {
|
||||
"AS7341 (light)": ("as7341_",),
|
||||
"BME688 (air)": ("bme688",),
|
||||
"SEN66 (air quality)": ("sen66",),
|
||||
}
|
||||
STATUS = "device_status"
|
||||
MIC = "microphone"
|
||||
|
||||
OK = "ok" # all sensor groups arrive
|
||||
SENSORS_MISSING = "sensors_missing" # status/microphone arrive, (some) sensor data does not
|
||||
SILENT = "silent" # data exists, but nothing recent
|
||||
NEVER = "never" # no data for this box in the lookback window
|
||||
UNAVAILABLE = "unavailable" # no token / no Internet / query failed
|
||||
OUTCOMES = (OK, SENSORS_MISSING, SILENT, NEVER, UNAVAILABLE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InfluxConfig:
|
||||
token: str
|
||||
url: str = DEFAULT_URL
|
||||
org: str = DEFAULT_ORG
|
||||
bucket: str = DEFAULT_BUCKET
|
||||
|
||||
|
||||
@dataclass
|
||||
class InfluxCheck:
|
||||
outcome: str
|
||||
summary: str
|
||||
latest: dict[str, dt.datetime] = field(default_factory=dict) # measurement -> newest point
|
||||
missing: list[str] = field(default_factory=list) # sensor groups without fresh data
|
||||
details: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_query(bucket: str, mac: str) -> str:
|
||||
"""Newest point per measurement of one box (future times included)."""
|
||||
return "\n".join([
|
||||
f"from(bucket: {json.dumps(bucket)})",
|
||||
f" |> range(start: {LOOKBACK}, stop: 2100-01-01T00:00:00Z)",
|
||||
f" |> filter(fn: (r) => r.device == {json.dumps(mac)})",
|
||||
" |> last()",
|
||||
# Drop _value first: string and float fields cannot share a table.
|
||||
' |> keep(columns: ["_measurement", "_time"])',
|
||||
' |> group(columns: ["_measurement"])',
|
||||
' |> max(column: "_time")',
|
||||
" |> group()",
|
||||
])
|
||||
|
||||
|
||||
def parse_time(value: str) -> dt.datetime:
|
||||
"""RFC 3339 from InfluxDB; nanoseconds are cut to microseconds."""
|
||||
value = value.strip().replace("Z", "+00:00")
|
||||
if "." in value:
|
||||
head, rest = value.split(".", 1)
|
||||
frac, sign, tz = rest.partition("+") if "+" in rest else rest.partition("-")
|
||||
value = f"{head}.{frac[:6]}{sign}{tz}"
|
||||
return dt.datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def parse_csv(text: str) -> dict[str, dt.datetime]:
|
||||
"""Annotated/plain CSV from /api/v2/query -> {measurement: newest time}."""
|
||||
latest: dict[str, dt.datetime] = {}
|
||||
header: list[str] | None = None
|
||||
for row in csv.reader(io.StringIO(text)):
|
||||
if not row or not any(row):
|
||||
header = None # tables are separated by an empty line
|
||||
continue
|
||||
if row[0].startswith("#"):
|
||||
continue
|
||||
if header is None:
|
||||
header = row
|
||||
continue
|
||||
rec = dict(zip(header, row))
|
||||
m, t = rec.get("_measurement"), rec.get("_time")
|
||||
if m and t:
|
||||
ts = parse_time(t)
|
||||
if m not in latest or ts > latest[m]:
|
||||
latest[m] = ts
|
||||
return latest
|
||||
|
||||
|
||||
def _ago(now: dt.datetime, t: dt.datetime) -> str:
|
||||
minutes = (now - t).total_seconds() / 60
|
||||
if minutes < -1:
|
||||
return f"{-minutes:.0f} min in the FUTURE"
|
||||
if minutes < 90:
|
||||
return f"{max(minutes, 0):.0f} min ago"
|
||||
if minutes < 48 * 60:
|
||||
return f"{minutes / 60:.0f} h ago"
|
||||
return f"{minutes / 1440:.0f} days ago"
|
||||
|
||||
|
||||
def classify(latest: dict[str, dt.datetime], now: dt.datetime, fresh_min: int = FRESH_MIN,
|
||||
since: dt.datetime | None = None) -> InfluxCheck:
|
||||
"""`since`: only data newer than this counts (the box was restarted then)."""
|
||||
def newest(prefixes: tuple[str, ...]) -> dt.datetime | None:
|
||||
times = [t for m, t in latest.items() if m.startswith(prefixes)]
|
||||
return max(times) if times else None
|
||||
|
||||
def fresh(t: dt.datetime | None) -> bool:
|
||||
if t is None or (now - t).total_seconds() > fresh_min * 60:
|
||||
return False
|
||||
return since is None or (since - t).total_seconds() <= SINCE_SLACK_S
|
||||
|
||||
details = []
|
||||
for label, prefixes in (("Device status", (STATUS,)), ("Microphone", (MIC,)),
|
||||
*SENSOR_GROUPS.items()):
|
||||
t = newest(prefixes)
|
||||
details.append(f"{label}: " + (f"{t:%Y-%m-%d %H:%M} UTC ({_ago(now, t)})" if t else "no data"))
|
||||
if t is not None and (t - now).total_seconds() > FUTURE_TOLERANCE_MIN * 60:
|
||||
details.append(f"{label}: timestamp is in the future - the box clock is wrong")
|
||||
missing = [g for g, p in SENSOR_GROUPS.items() if not fresh(newest(p))]
|
||||
status_t = max(filter(None, (newest((STATUS,)), newest((MIC,)))), default=None)
|
||||
sensors_t = [newest(p) for p in SENSOR_GROUPS.values()]
|
||||
lagging = status_t is not None and any(t is None or (status_t - t).total_seconds() > fresh_min * 60
|
||||
for t in sensors_t)
|
||||
if lagging:
|
||||
details.append("Sensor data stopped (or never started) while the status still arrived")
|
||||
|
||||
if not latest:
|
||||
return InfluxCheck(NEVER, f"No data from this box in InfluxDB in the last {LOOKBACK[1:]}",
|
||||
latest, missing, details)
|
||||
if not missing:
|
||||
return InfluxCheck(OK, "Sensor data arrives in InfluxDB", latest, missing, details)
|
||||
if fresh(status_t):
|
||||
return InfluxCheck(SENSORS_MISSING, "Status arrives in InfluxDB, sensor data does not: "
|
||||
+ ", ".join(missing), latest, missing, details)
|
||||
if lagging:
|
||||
# Offline now, but the history already shows the fault.
|
||||
return InfluxCheck(SENSORS_MISSING, f"Nothing new in InfluxDB (last status {_ago(now, status_t)}); "
|
||||
"before that the status arrived without sensor data: " + ", ".join(missing),
|
||||
latest, missing, details)
|
||||
if since is not None:
|
||||
return InfluxCheck(SILENT, "No new data in InfluxDB since the test started", latest, missing, details)
|
||||
return InfluxCheck(SILENT, f"No recent data in InfluxDB (nothing in the last {fresh_min} min)",
|
||||
latest, missing, details)
|
||||
|
||||
|
||||
def query_latest(cfg: InfluxConfig, mac: str) -> dict[str, dt.datetime]:
|
||||
url = cfg.url.rstrip("/") + "/api/v2/query?" + urllib.parse.urlencode({"org": cfg.org})
|
||||
req = urllib.request.Request(url, data=build_query(cfg.bucket, mac).encode(), method="POST", headers={
|
||||
"Authorization": f"Token {cfg.token}",
|
||||
"Content-Type": "application/vnd.flux",
|
||||
"Accept": "application/csv",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
|
||||
return parse_csv(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def check_device(cfg: InfluxConfig | None, mac: str, now: dt.datetime | None = None,
|
||||
since: dt.datetime | None = None) -> InfluxCheck:
|
||||
"""Blocking; run it off the UI thread. Never raises."""
|
||||
mac = "".join(c for c in (mac or "") if c.isalnum()).upper()
|
||||
if cfg is None or not cfg.token:
|
||||
return InfluxCheck(UNAVAILABLE, "InfluxDB access is not set up (Settings > InfluxDB access)")
|
||||
if len(mac) != 12:
|
||||
return InfluxCheck(UNAVAILABLE, "The box MAC address is not known yet")
|
||||
try:
|
||||
latest = query_latest(cfg, mac)
|
||||
except urllib.error.HTTPError as exc:
|
||||
why = {401: "the token was rejected", 403: "the token has no read access",
|
||||
404: "organisation or bucket not found"}.get(exc.code, f"HTTP {exc.code}")
|
||||
return InfluxCheck(UNAVAILABLE, f"InfluxDB query failed: {why}")
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
return InfluxCheck(UNAVAILABLE, f"InfluxDB not reachable from this computer ({reason})")
|
||||
except (ValueError, csv.Error) as exc:
|
||||
return InfluxCheck(UNAVAILABLE, f"Unexpected InfluxDB answer ({exc})")
|
||||
return classify(latest, now or dt.datetime.now(dt.timezone.utc), since=since)
|
||||
@@ -18,7 +18,7 @@ from .. import __version__
|
||||
from ..diag.engine import Report, Severity
|
||||
|
||||
DEFAULT_BASE = "https://service.kota-app.com/"
|
||||
NOTE_LIMIT = 4000 # the service form accepts 5000; leave room for the technician
|
||||
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"
|
||||
@@ -33,13 +33,26 @@ CONNECTIVITY = "Problem z łącznością"
|
||||
_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),
|
||||
("influx.bad_request", SOFTWARE), ("influx.invalid_value", 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),
|
||||
("tech.influx.invalid_value", SOFTWARE), ("tech.influx.sensors_clock", HARDWARE), ("tech.influx.silent", CONNECTIVITY),
|
||||
("tech.influx.never", CONNECTIVITY), ("tech.influx.", CHECK),
|
||||
# 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}$")
|
||||
@@ -61,26 +74,69 @@ def service_status(report: Report) -> str:
|
||||
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:
|
||||
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}",
|
||||
f"Firmware: {report.firmware.describe()}",
|
||||
f"Expected LED: {report.led.capitalize()}"]
|
||||
reached = [label for _, label, t in report.milestones if t is not None]
|
||||
missing = [label for _, label, t in report.milestones if t is None]
|
||||
if reached:
|
||||
lines.append("OK: " + ", ".join(reached))
|
||||
if missing:
|
||||
lines.append("Not reached: " + ", ".join(missing))
|
||||
"""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:
|
||||
f = rc.finding
|
||||
lines.append(f"- [{f.severity.label}] {f.title}")
|
||||
lines += _finding(rc.finding, "", detail in ("all", "roots"))
|
||||
for c in rc.consequences:
|
||||
lines.append(f" -> {c.title}")
|
||||
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.append("Technician checks:")
|
||||
lines += ["", "Technician checks:"]
|
||||
lines += [f" * {t}" for t in report.technician[-12:]]
|
||||
text = "\n".join(lines)
|
||||
return text if len(text) <= limit else text[:limit - 20].rstrip() + "\n...(shortened)"
|
||||
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:
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
"""Quick-start cheat sheet shown on launch.
|
||||
|
||||
Targets the DEPLOYED firmware: new_system.bin, app "kota-sensorboard"
|
||||
1.0, built Jun 29 2026 11:03:32, ELF SHA256 45fa9d5b9... (verified on a
|
||||
live board). That build has no Wi-Fi setup portal / SSID-on-SD support;
|
||||
those exist only in the newer, unbuilt source. The status LED is always
|
||||
SOLID; the separate small LED toggles every second while alive.
|
||||
LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md,
|
||||
status.cpp Status::pattern_for): colour AND pattern name the fault. Older
|
||||
boxes (v1.0, built Jun 29 2026) show the same colours, always solid.
|
||||
"""
|
||||
|
||||
# (swatch colour, LED name, meaning, first action)
|
||||
# (swatch colour, LED name, pattern -> meaning, first action)
|
||||
LED_TABLE = [
|
||||
("#2e9e44", "Green", "Everything OK.", "Nothing to do."),
|
||||
("#8e44ad", "Purple",
|
||||
"No Internet / InfluxDB or OTA server unreachable.",
|
||||
"Check the box is commissioned in the Matter app (sticker QR code) and the "
|
||||
"site Wi-Fi has Internet. Board reboots itself after ~2.5 min of purple."),
|
||||
("#d64541", "Red",
|
||||
"Sensor or hardware fault (BME688, SEN66, AS7341, microphone, flicker) "
|
||||
"or failed self-check after update.",
|
||||
"Connect serial - the log names the failing sensor. Check sensor cables."),
|
||||
("#2f6fd6", "Blue",
|
||||
"Clock (RTC DS1307) failed to start. The RTC is the first I2C device, "
|
||||
"so a broken I2C cable also shows blue.",
|
||||
"Check the I2C harness first, then the RTC module / coin cell."),
|
||||
("#e6c229", "Yellow", "Firmware update downloading.", "Do not switch off. Wait."),
|
||||
("#f08c1a", "Orange", "Self-check after an update (~2.5 min).",
|
||||
"Wait; should turn green (or red on failure)."),
|
||||
("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."),
|
||||
("#1fc8d8", "Cyan",
|
||||
"breathing: not paired in Matter yet<br>fast blink: a phone is connected for pairing.",
|
||||
"Pair the box in the Matter app (QR sticker or the QR code in the tool)."),
|
||||
("#f4f4f4", "White",
|
||||
"SD card missing/unreadable or ENV.TXT incomplete - or a factory reset is "
|
||||
"in progress (Matter button held 5 s).",
|
||||
"If nobody pressed the Matter button: the SD card is inside the case - "
|
||||
"unscrew, re-seat or re-provision it."),
|
||||
"fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>"
|
||||
"breathing: Wi-Fi setup portal.",
|
||||
"SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."),
|
||||
("#d64541", "Red",
|
||||
"1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed<br>"
|
||||
"solid: other internal error / out of memory.",
|
||||
"Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."),
|
||||
("#2f6fd6", "Blue",
|
||||
"1 pulse: clock (RTC) not responding<br>2 pulses: I2C bus stuck (shorted / broken sensor cable).",
|
||||
"Check the I2C cable between the boards first, then the RTC module."),
|
||||
("#8e44ad", "Purple",
|
||||
"solid: no network<br>blinking: server rejected the box (wrong ENV.TXT credentials).",
|
||||
"Solid: check Wi-Fi / Internet. Blinking: re-provision the SD card with the current ENV.TXT."),
|
||||
("#e6c229", "Yellow", "blinking, faster over time: downloading an update.", "Do not switch off. Wait."),
|
||||
("#f08c1a", "Orange", "solid: self-check after an update (~2.5 min).",
|
||||
"Wait; should turn green (or red on failure)."),
|
||||
("#444444", "Off", "Firmware did not start (or crashed very early).",
|
||||
"Check power switch & USB cable. If the small LED is also dark: re-flash."),
|
||||
]
|
||||
|
||||
HINTS = [
|
||||
"<b>Colour keeps changing / LED goes dark every few seconds</b> = reboot loop. "
|
||||
"Connect serial and press <i>Reset & capture</i>: the last lines before the "
|
||||
"reboot name the cause.",
|
||||
"<b>Small LED next to the RGB LED</b> blinks once per second while the "
|
||||
"firmware runs. Not blinking = firmware hung or not running.",
|
||||
"<b>Before a self-restart</b> the LED flashes the error colour 3 times; <b>after the restart</b> it "
|
||||
"shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press "
|
||||
"<i>Reset & capture</i>: the tool reads the restart reason from the box.",
|
||||
"<b>Small LED next to the RGB LED</b> (heartbeat): blinks once per second while online, "
|
||||
"double-blinks while offline. Dark or steady = firmware hung or not running.",
|
||||
"<b>Serial monitor</b>: lines starting with <b>KOTA:</b> are the box's own diagnosis (boot, I2C, SD, "
|
||||
"clock, Matter, microphone, status, LED, and a health line every 60 s). The Diagnosis panel reads them "
|
||||
"directly; filter the view with <i>KOTA diagnostics + errors</i>. Error lines carry a code like "
|
||||
"<b>[E-SD-03]</b> - the tool shows the recommended action for it.",
|
||||
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
|
||||
"(off = no noise measurements), and the Matter button. Holding the "
|
||||
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
|
||||
"network and must be commissioned again with the sticker QR code).",
|
||||
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
|
||||
"Normally not needed - the tool resets the board over USB.",
|
||||
"<b>Gathering messages for the first ~2 minutes</b> are normal "
|
||||
"(the air-quality sensor warms up for 120 s).",
|
||||
"<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
|
||||
"by its compile date.",
|
||||
]
|
||||
|
||||
STEPS = [
|
||||
"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.",
|
||||
"Connect the box so that a <b>USB 2.0 type-A plug</b> is on the way to the laptop (into the laptop or a "
|
||||
"hub; USB-C only often fails). The cable must carry data.",
|
||||
"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.",
|
||||
@@ -73,10 +76,10 @@ def cheatsheet_html() -> str:
|
||||
return f"""
|
||||
<h2>Sensorbox quick start</h2>
|
||||
<ol>{steps}</ol>
|
||||
<h3>RGB status LED (always solid colour)</h3>
|
||||
<h3>RGB status LED - colour and pattern</h3>
|
||||
<table border='1' cellspacing='0' cellpadding='0' width='100%'>
|
||||
<tr><th align='left' style='padding:6px'>LED</th>
|
||||
<th align='left' style='padding:6px'>Most likely meaning</th>
|
||||
<th align='left' style='padding:6px'>Pattern: meaning</th>
|
||||
<th align='left' style='padding:6px'>First action</th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Diagnostics side panel: renders a diag.engine.Report.
|
||||
|
||||
Layout: verdict banner, expected LED, firmware, start-up milestones, a
|
||||
findings tree (root causes; their consequences as children; titles only)
|
||||
and a wrapped detail pane for the selected finding.
|
||||
Layout: verdict banner, the test checklist (start-up milestones plus the
|
||||
online InfluxDB check), a findings tree (root causes; their consequences as
|
||||
children; titles only) and a wrapped detail pane for the selected finding.
|
||||
`details` is a separate widget for the host to place: expected LED (colour +
|
||||
pattern + meaning when the box reports it), firmware and the box's own
|
||||
self-report (KOTA lines).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextBrowser,
|
||||
@@ -41,13 +45,20 @@ BANNER = {
|
||||
None: ("#d8f0dd", "#1b5e2a"),
|
||||
}
|
||||
_KEY = Qt.ItemDataRole.UserRole
|
||||
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
|
||||
# Extra checklist rows (e.g. the InfluxDB check): state -> (mark, colour)
|
||||
ROW_STATE = {"ok": ("✔", "#1b7a33"), "bad": ("✖", "#b03a2e"), "warn": ("!", "#a86400"),
|
||||
"running": ("…", "#2e6da4"), "pending": ("○", "#6b6b6b"), "off": ("–", "#6b6b6b"),
|
||||
"note": ("", "#444444")}
|
||||
|
||||
|
||||
def _heading(text: str) -> QLabel:
|
||||
lbl = QLabel(text)
|
||||
f = lbl.font()
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
lbl.setFont(f)
|
||||
lbl.setContentsMargins(0, 6, 0, 2)
|
||||
return lbl
|
||||
|
||||
|
||||
@@ -77,10 +88,22 @@ class DiagnosticsPanel(QWidget):
|
||||
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.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.milestones.setWordWrap(True)
|
||||
self.milestones.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
self.milestones.setStyleSheet("QListWidget { background: transparent; } QListWidget::item { padding: 2px; }")
|
||||
mf = self.milestones.font()
|
||||
mf.setPointSize(mf.pointSize() + 1)
|
||||
self.milestones.setFont(mf)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderHidden(True)
|
||||
@@ -94,20 +117,31 @@ class DiagnosticsPanel(QWidget):
|
||||
findings.setSizes([220, 260])
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.setContentsMargins(4, 0, 4, 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(_heading("Start-up progress (current boot)"))
|
||||
lay.addWidget(_heading("Test checklist (current boot)"))
|
||||
lay.addWidget(self.milestones)
|
||||
lay.addWidget(_heading("Findings - root causes first; click one for details"))
|
||||
lay.addWidget(findings, 1)
|
||||
|
||||
# Box details: placed by the host (a separate tab).
|
||||
inner = QWidget()
|
||||
dl = QVBoxLayout(inner)
|
||||
dl.addWidget(_heading("Status LED should be"))
|
||||
dl.addLayout(led_row)
|
||||
dl.addWidget(_heading("Firmware"))
|
||||
dl.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")
|
||||
dl.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
dl.addWidget(self.box_title)
|
||||
dl.addWidget(self.box)
|
||||
dl.addStretch(1)
|
||||
self.details = QScrollArea()
|
||||
self.details.setWidgetResizable(True)
|
||||
self.details.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.details.setWidget(inner)
|
||||
|
||||
self._findings: dict[str, Finding] = {}
|
||||
self._selected: str | None = None
|
||||
# key -> expanded, chosen by the user; survives the rebuild on refresh.
|
||||
@@ -117,7 +151,8 @@ class DiagnosticsPanel(QWidget):
|
||||
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def show_report(self, r: Report) -> None:
|
||||
def show_report(self, r: Report, extra_rows: list[tuple[str, str]] = ()) -> None:
|
||||
"""extra_rows: (state, text) appended to the checklist, state in ROW_STATE."""
|
||||
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
|
||||
if r.lines == 0:
|
||||
bg, fg = "#e6e6e6", "#333333"
|
||||
@@ -128,8 +163,11 @@ class DiagnosticsPanel(QWidget):
|
||||
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}</b>{ask}{reason}")
|
||||
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",
|
||||
@@ -137,15 +175,32 @@ class DiagnosticsPanel(QWidget):
|
||||
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")))
|
||||
it.setForeground(QBrush(QColor("#1b7a33" if done else "#6b6b6b")))
|
||||
self.milestones.addItem(it)
|
||||
rows = min(max(self.milestones.count(), 1), 15)
|
||||
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
|
||||
for state, text in extra_rows:
|
||||
mark, colour = ROW_STATE[state]
|
||||
it = QListWidgetItem(f"{mark} {text}" if mark else text)
|
||||
it.setForeground(QBrush(QColor(colour)))
|
||||
if state != "note":
|
||||
f = it.font()
|
||||
f.setBold(True)
|
||||
it.setFont(f)
|
||||
it.setToolTip(text)
|
||||
self.milestones.addItem(it)
|
||||
height = sum(max(self.milestones.sizeHintForRow(i), 16) for i in range(self.milestones.count()))
|
||||
self.milestones.setFixedHeight(min(max(height, 16), 560) + 6)
|
||||
|
||||
self._fill_tree(r)
|
||||
|
||||
@@ -218,16 +273,15 @@ class DiagnosticsPanel(QWidget):
|
||||
def _detail_html(f: Finding) -> str:
|
||||
colour = SEV_COLOR.get(f.severity, "#333")
|
||||
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
|
||||
seen = f"Seen {f.count}x"
|
||||
if f.first_uptime_ms is not None:
|
||||
seen += f", first at {f.first_uptime_ms / 1000:.1f} s after start"
|
||||
if f.resolved and f.resolved_by:
|
||||
seen += f" - resolved ({_esc(f.resolved_by)})"
|
||||
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 "")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Flash firmware tab: factory image or imported developer builds; App-only by default."""
|
||||
"""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
|
||||
|
||||
@@ -6,12 +7,11 @@ import datetime as dt
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
@@ -26,7 +26,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..core.session import SESSIONS_DIR
|
||||
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, bundled_images
|
||||
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
|
||||
@@ -35,7 +36,7 @@ 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 "
|
||||
"<b>If flashing cannot connect:</b> use a data-capable cable with a USB 2.0 type-A plug on the way, 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."
|
||||
)
|
||||
@@ -72,32 +73,32 @@ class FlashTab(QWidget):
|
||||
bl.addLayout(row)
|
||||
bl.addWidget(self.box_fw)
|
||||
|
||||
# 2. firmware
|
||||
fwbox = QGroupBox("2. Firmware")
|
||||
# 2. firmware - only files in the tool's firmware/ folder
|
||||
fwbox = QGroupBox("2. Firmware (by compile date)")
|
||||
self.image_combo = QComboBox(minimumWidth=360)
|
||||
self.import_btn = QPushButton("Import firmware file...")
|
||||
self.import_btn.setToolTip("Add a newer build the firmware team sent you (.bin)")
|
||||
self.import_btn.clicked.connect(self._import)
|
||||
self.remove_btn = QPushButton("Remove from list")
|
||||
self.remove_btn.clicked.connect(self._remove)
|
||||
self.image_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.import_btn)
|
||||
brow.addWidget(self.remove_btn)
|
||||
brow.addWidget(self.reload_btn)
|
||||
brow.addWidget(self.folder_btn)
|
||||
brow.addStretch(1)
|
||||
fl.addLayout(brow)
|
||||
fl.addWidget(self.image_info)
|
||||
self._library = FirmwareLibrary()
|
||||
self._reference: FirmwareImage | None = None
|
||||
self._problems: list[str] = []
|
||||
|
||||
# 3. mode
|
||||
modebox = QGroupBox("3. What to write")
|
||||
self.rb_app = QRadioButton("App only (recommended for repairs)")
|
||||
self.rb_full = QRadioButton("Full factory image (new or completely wiped boards)")
|
||||
self.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)
|
||||
@@ -167,60 +168,23 @@ class FlashTab(QWidget):
|
||||
|
||||
# ------------------------------------------------------------ setup
|
||||
def _load_images(self, select_sha: str | None = None) -> None:
|
||||
self._problems = []
|
||||
try:
|
||||
bundled = bundled_images()
|
||||
except ImageError as exc:
|
||||
bundled = []
|
||||
self._problems.append(f"bundled image: {exc}")
|
||||
factory = [i for i in bundled if not i.is_developer]
|
||||
self._reference = factory[0] if factory else None
|
||||
dev, problems = self._library.images(self._reference)
|
||||
dev += [i for i in bundled if i.is_developer]
|
||||
self._problems += problems
|
||||
# Newest developer builds first, then the factory image.
|
||||
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True)
|
||||
self._images = dev + factory
|
||||
self._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 image available - import one")
|
||||
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), None)
|
||||
if idx is None:
|
||||
idx = len(dev) if factory else 0 # default: the factory image
|
||||
self.image_combo.setCurrentIndex(idx)
|
||||
self.image_combo.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 _import(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Import firmware from the firmware team", str(Path.home()),
|
||||
"Firmware (*.bin);;All files (*)")
|
||||
if not path:
|
||||
return
|
||||
built_by, ok = QInputDialog.getText(self, "Who built this firmware?",
|
||||
"Name of the developer who built this image (shown next to it):")
|
||||
if not ok:
|
||||
return
|
||||
try:
|
||||
img = self._library.import_file(Path(path), self._reference, built_by=built_by)
|
||||
except (ImageError, OSError) as exc:
|
||||
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}")
|
||||
return
|
||||
self._load_images(select_sha=img.sha256)
|
||||
kind = "complete image" if img.kind == "merged" else "app only"
|
||||
QMessageBox.information(
|
||||
self, "Firmware imported",
|
||||
f"{img.name}\nbuild {img.app.elf_sha[:9]}, {kind}\n\nFile fingerprint (SHA-256):\n{img.sha256}\n\n"
|
||||
"Compare it with the fingerprint the firmware team sent you before flashing.")
|
||||
|
||||
def _remove(self) -> None:
|
||||
def _selected_sha(self) -> str | None:
|
||||
img = self._selected_image()
|
||||
if img and img.is_developer and QMessageBox.question(
|
||||
self, "Remove firmware", f"Remove {img.name} from this computer?") == QMessageBox.StandardButton.Yes:
|
||||
self._library.remove(img.sha256)
|
||||
self._load_images()
|
||||
return img.sha256 if img else None
|
||||
|
||||
def refresh_ports(self) -> None:
|
||||
current = self.port_combo.currentData()
|
||||
@@ -251,18 +215,17 @@ class FlashTab(QWidget):
|
||||
info = ""
|
||||
if img:
|
||||
a = img.app
|
||||
origin = ("<b>Factory image</b> (bundled with the tool)" if not img.is_developer else
|
||||
"<b style='color:#c77c02'>Developer build</b> - only flash builds you received "
|
||||
"from the firmware team")
|
||||
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
|
||||
info = (f"{origin}<br>{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}, {kind}<br>"
|
||||
f"<small>build {a.elf_sha[:9]} · file SHA-256 {img.sha256[:16]}... "
|
||||
f"· checksum OK</small>" + (f"<br><small>{img.notes}</small>" if img.notes else ""))
|
||||
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)
|
||||
self.remove_btn.setEnabled(bool(img and img.is_developer) and self._runner is None)
|
||||
# An app-only file has no bootloader/layout: Full mode is impossible.
|
||||
full_ok = bool(img and img.kind == "merged")
|
||||
self.rb_full.setEnabled(full_ok and self._runner is None)
|
||||
@@ -280,7 +243,7 @@ class FlashTab(QWidget):
|
||||
if mode == Mode.APP_ONLY:
|
||||
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
|
||||
else:
|
||||
steps += ["Write the complete factory image (~25 s)"]
|
||||
steps += ["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:
|
||||
@@ -290,12 +253,13 @@ class FlashTab(QWidget):
|
||||
fw = self.serial_tab.engine.firmware
|
||||
if not fw.elf_sha:
|
||||
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
|
||||
msg = f"Box currently runs: {fw.describe()}" + (f", built {fw.built}" if fw.built else "")
|
||||
msg = f"Box currently runs: {fw.describe()}"
|
||||
if not img:
|
||||
return msg
|
||||
if fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha):
|
||||
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>"
|
||||
box_built, img_built = _parse_built(fw.built), img.app.built_at
|
||||
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:
|
||||
@@ -310,9 +274,10 @@ class FlashTab(QWidget):
|
||||
return
|
||||
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
|
||||
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
|
||||
else "the COMPLETE factory image (pairing and OTA registration are erased)")
|
||||
else "the COMPLETE image (pairing and OTA registration are erased)")
|
||||
if QMessageBox.question(self, "Flash firmware",
|
||||
f"Write {what}\n\nImage: {img.name}\nBox: {port.label}\n\n"
|
||||
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:
|
||||
@@ -335,7 +300,8 @@ class FlashTab(QWidget):
|
||||
self._runner.start()
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full, self.import_btn):
|
||||
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()
|
||||
|
||||
@@ -363,8 +329,8 @@ class FlashTab(QWidget):
|
||||
self._verify_ms += self._verify_timer.interval()
|
||||
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
|
||||
fw = self.serial_tab.engine.firmware
|
||||
want = self._image.app.elf_sha[:9]
|
||||
done = fw.elf_sha is not None and fw.elf_sha.startswith(want)
|
||||
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)
|
||||
@@ -374,7 +340,7 @@ class FlashTab(QWidget):
|
||||
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
|
||||
"Check the Diagnosis panel in the Serial monitor.", "ok")
|
||||
else:
|
||||
seen = fw.elf_sha[:9] if fw.elf_sha else "no boot seen"
|
||||
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")
|
||||
|
||||
|
||||
@@ -167,7 +167,8 @@ class GuidePanel(QWidget):
|
||||
self.wait_bar.setMaximum(int(total))
|
||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||
m, s = divmod(int(v.remaining_s), 60)
|
||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
||||
doing = "Checking InfluxDB" if v.step.type == "influx" else "Watching the live log"
|
||||
self.wait_label.setText(f"{doing}... {m}:{s:02d} left")
|
||||
self.note.setText(v.note)
|
||||
self.note.setVisible(bool(v.note))
|
||||
self.back_btn.setEnabled(v.can_back)
|
||||
@@ -212,9 +213,10 @@ class GuidePanel(QWidget):
|
||||
self.options.addWidget(b)
|
||||
step_type = v.step.type if v.step else None
|
||||
self.continue_btn.setVisible(step_type == "info")
|
||||
self.skip_btn.setVisible(step_type == "wait")
|
||||
self.wait_bar.setVisible(step_type == "wait")
|
||||
self.wait_label.setVisible(step_type == "wait")
|
||||
waits = step_type in ("wait", "influx")
|
||||
self.skip_btn.setVisible(waits)
|
||||
self.wait_bar.setVisible(waits)
|
||||
self.wait_label.setVisible(waits)
|
||||
self.end_box.setVisible(is_end)
|
||||
if is_end:
|
||||
e = v.end
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
|
||||
@@ -7,17 +12,23 @@ from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTex
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -28,9 +39,11 @@ 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 import influx
|
||||
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
||||
from .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
@@ -38,9 +51,19 @@ 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
|
||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||
TEST_INFLUX_AFTER_S = 30 # Test box: start the online check this long after the restart
|
||||
INFLUX_ROW = "Sensor data in InfluxDB (online check)"
|
||||
# test status strip: (background, text colour)
|
||||
STRIP = {"idle": ("#eef1f4", "#333333"), "running": ("#dde9f7", "#123a63"), "ok": ("#d8f0dd", "#1b5e2a"),
|
||||
"info": ("#dde9f7", "#123a63"), "warning": ("#fdebc8", "#6b4400"), "error": ("#f9dedc", "#8a2019"),
|
||||
"critical": ("#f6d5d1", "#7b1e14")}
|
||||
CLOUD_STATE = {"ok": "ok", "sensors_missing": "bad", "silent": "warn", "never": "warn", "unavailable": "off"}
|
||||
# "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",
|
||||
@@ -49,15 +72,21 @@ COLORS = {
|
||||
LineKind.RESET: "#6fc3ff",
|
||||
LineKind.PANIC: "#ff4dd2",
|
||||
LineKind.HOST: "#7ee07e",
|
||||
LineKind.KOTA: "#5fd7e8",
|
||||
LineKind.RAW: "#a8a8a8",
|
||||
}
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
|
||||
|
||||
line = Signal(object)
|
||||
state = Signal(object, str)
|
||||
influx = Signal(object) # InfluxCheck from the query thread
|
||||
|
||||
|
||||
class SerialTab(QWidget):
|
||||
@@ -66,6 +95,7 @@ class SerialTab(QWidget):
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.line.connect(self._on_line)
|
||||
self._bridge.state.connect(self._on_state)
|
||||
self._bridge.influx.connect(self._on_influx)
|
||||
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
|
||||
self._session = SessionLog()
|
||||
self._ports: list[PortInfo] = []
|
||||
@@ -73,84 +103,130 @@ class SerialTab(QWidget):
|
||||
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._settings = QSettings("KOTA", "TechnicianTool")
|
||||
self.port_combo = QComboBox(minimumWidth=360)
|
||||
self.refresh_btn = QPushButton("↻")
|
||||
self.refresh_btn.setToolTip("Look for boxes again")
|
||||
self.refresh_btn.setFixedWidth(40)
|
||||
self.test_btn = QPushButton("▶ Test box")
|
||||
self.test_btn.setToolTip("Restart the box, check its start-up and whether its sensor data reaches "
|
||||
"InfluxDB (about 4 minutes)")
|
||||
self.test_btn.setMinimumHeight(38)
|
||||
self.test_btn.setStyleSheet(
|
||||
"QPushButton { background:#1f7a3a; color:white; font-weight:bold; font-size:15px; "
|
||||
"padding:4px 22px; border-radius:6px; border:none; }"
|
||||
"QPushButton:hover { background:#25904a; } QPushButton:disabled { background:#9dbfa6; }")
|
||||
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.connect_btn.setMinimumHeight(32)
|
||||
|
||||
# Less frequent actions live in the "More" menu.
|
||||
self.more_btn = QToolButton(text="More ▾")
|
||||
self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
self.more_btn.setMinimumHeight(32)
|
||||
menu = QMenu(self.more_btn)
|
||||
self.reset_btn = menu.addAction("Reset && capture (restart the box, no online check)")
|
||||
menu.addSeparator()
|
||||
self.service_btn = menu.addAction("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.copy_btn = menu.addAction("Copy report")
|
||||
self.save_btn = menu.addAction("Save log view as...")
|
||||
self.folder_btn = menu.addAction("Open logs folder")
|
||||
self.analyze_btn = menu.addAction("Analyze log file...")
|
||||
menu.addSeparator()
|
||||
self.auto_service = menu.addAction("Open service report on disconnect")
|
||||
self.auto_service.setCheckable(True)
|
||||
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.all_ports = menu.addAction("Show all serial ports")
|
||||
self.all_ports.setCheckable(True)
|
||||
self.more_btn.setMenu(menu)
|
||||
self._active_port: PortInfo | None = None
|
||||
|
||||
top = QHBoxLayout()
|
||||
box_lbl = QLabel("<b>Box:</b>")
|
||||
for w in (box_lbl, self.port_combo, self.refresh_btn):
|
||||
top.addWidget(w)
|
||||
top.addSpacing(12)
|
||||
top.addWidget(self.test_btn)
|
||||
top.addWidget(self.connect_btn)
|
||||
top.addStretch(1)
|
||||
top.addWidget(self.more_btn)
|
||||
|
||||
# Test status strip (what the test is doing / its result).
|
||||
self.test_status = QLabel(wordWrap=True)
|
||||
self.test_status.setMargin(8)
|
||||
tf = self.test_status.font()
|
||||
tf.setPointSize(tf.pointSize() + 1)
|
||||
self.test_status.setFont(tf)
|
||||
|
||||
# --- live log (a tab on the right)
|
||||
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)
|
||||
mono.setPointSize(12 if sys.platform == "darwin" else 10)
|
||||
self.view.setFont(mono)
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
log_page = QWidget()
|
||||
ll = QVBoxLayout(log_page)
|
||||
ll.setContentsMargins(0, 4, 0, 0)
|
||||
lrow = QHBoxLayout()
|
||||
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn):
|
||||
lrow.addWidget(w)
|
||||
lrow.addStretch(1)
|
||||
ll.addLayout(lrow)
|
||||
ll.addWidget(self.view, 1)
|
||||
|
||||
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(log_page, "Live log")
|
||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||
self.side_tabs.addTab(self.diag.details, "Box details")
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.addWidget(self.view)
|
||||
split.addWidget(self.diag)
|
||||
split.addWidget(self.side_tabs)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 2)
|
||||
split.setSizes([700, 480])
|
||||
split.setStretchFactor(0, 1)
|
||||
split.setStretchFactor(1, 1)
|
||||
split.setSizes([560, 620])
|
||||
|
||||
self.status = QLabel()
|
||||
self.status.setStyleSheet("color:#555")
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(row1)
|
||||
layout.addLayout(row2)
|
||||
layout.addLayout(top)
|
||||
layout.addWidget(self.test_status)
|
||||
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.test_btn.clicked.connect(self._test_box)
|
||||
self.connect_btn.clicked.connect(self._toggle_connect)
|
||||
self.reset_btn.clicked.connect(self._manager.reset_board)
|
||||
self.reset_btn.triggered.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.save_btn.triggered.connect(self._save_view)
|
||||
self.folder_btn.triggered.connect(self._open_folder)
|
||||
self.analyze_btn.triggered.connect(self._analyze_file)
|
||||
self.copy_btn.triggered.connect(self._copy_report)
|
||||
self.service_btn.triggered.connect(lambda: self._open_service_report(auto=False))
|
||||
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
|
||||
|
||||
# Test box state: None = no test; else monotonic start time.
|
||||
self._test_t0: float | None = None
|
||||
self._test_since: dt.datetime | None = None # UTC; InfluxDB data must be newer
|
||||
self._influx_started = False
|
||||
self._cloud: influx.InfluxCheck | None = None # last InfluxDB answer
|
||||
self._cloud_checking = False
|
||||
|
||||
# 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)
|
||||
@@ -206,11 +282,102 @@ class SerialTab(QWidget):
|
||||
self._diag_dirty = True
|
||||
self._session.start(port.mac or port.device)
|
||||
self._active_port = port
|
||||
self._cloud, self._cloud_checking = None, False
|
||||
self._manager.connect(port)
|
||||
|
||||
# ---- Test box ----------------------------------------------------------
|
||||
def _test_box(self) -> None:
|
||||
"""Restart the box, record its start-up, then check InfluxDB online."""
|
||||
if self._manager.is_running:
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._cloud, self._cloud_checking = None, False
|
||||
self._manager.reset_board()
|
||||
else:
|
||||
port = self._selected_port() or next((p for p in self._ports if p.is_sensorbox), None)
|
||||
if port is None:
|
||||
QMessageBox.information(self, "Test box", "No sensorbox found. Check the USB cable (data cable) "
|
||||
"and that the box's power switch is ON, then press ↻.")
|
||||
return
|
||||
self.connect_port(port, capture=True)
|
||||
self._test_t0 = time.monotonic()
|
||||
self._test_since = dt.datetime.now(dt.timezone.utc)
|
||||
self._influx_started = False
|
||||
self._test_end = None
|
||||
self._diag_dirty = True
|
||||
self._guide_log("Test box started: restarting the box and recording its start-up")
|
||||
|
||||
def _tick_test(self) -> None:
|
||||
if self._test_t0 is None:
|
||||
return
|
||||
self._diag_dirty = True # countdowns in the strip and the checklist
|
||||
if not self._manager.is_running:
|
||||
self._finish_test(None, "Box disconnected - test stopped.")
|
||||
return
|
||||
elapsed = time.monotonic() - self._test_t0
|
||||
if not self._influx_started and elapsed >= TEST_INFLUX_AFTER_S and self.is_connected:
|
||||
self._influx_started = True
|
||||
self._runner.start("influx") # polls, routes and records findings like a guided check
|
||||
self.guide.refresh()
|
||||
elif self._influx_started and (self._runner.end is not None or self._runner.guide is None):
|
||||
self._finish_test(self._runner.end)
|
||||
|
||||
def _finish_test(self, end, text: str = "") -> None:
|
||||
self._test_t0 = None
|
||||
self._test_since = None
|
||||
self._test_end = end
|
||||
self._test_text = text
|
||||
self._guide_log("Test box finished: " + (end.title if end else text or "stopped"))
|
||||
|
||||
def _cloud_rows(self) -> list[tuple[str, str]]:
|
||||
in_check = self._runner.step is not None and self._runner.step.type == "influx"
|
||||
if self.influx_config() is None:
|
||||
return [("off", f"{INFLUX_ROW}: not set up - Settings > InfluxDB access")]
|
||||
if in_check:
|
||||
left = int(self._runner.view().remaining_s)
|
||||
last = f" - last answer: {self._cloud.summary}" if self._cloud else ""
|
||||
rows = [("running", f"{INFLUX_ROW}: checking... {left // 60}:{left % 60:02d} left{last}")]
|
||||
elif self._cloud is not None:
|
||||
rows = [(CLOUD_STATE.get(self._cloud.outcome, "warn"), f"{INFLUX_ROW}: {self._cloud.summary}")]
|
||||
else:
|
||||
when = (f"starts {TEST_INFLUX_AFTER_S} s after the restart" if self._test_t0 is not None
|
||||
else "press Test box")
|
||||
return [("pending", f"{INFLUX_ROW} - {when}")]
|
||||
if self._cloud is not None:
|
||||
rows += [("note", f" {d}") for d in self._cloud.details]
|
||||
return rows
|
||||
|
||||
def _update_test_strip(self) -> None:
|
||||
if self._test_t0 is not None:
|
||||
el = int(time.monotonic() - self._test_t0)
|
||||
if self._influx_started:
|
||||
doing = "checking that the data reaches InfluxDB - " + (
|
||||
self._runner.view().note or "waiting for the first answer")
|
||||
else:
|
||||
ms = self._engine.report().milestones
|
||||
done = sum(t is not None for _, _, t in ms)
|
||||
doing = f"recording the start-up ({done} of {len(ms)} checklist steps done)"
|
||||
key, text = "running", f"<b>Test running {el // 60}:{el % 60:02d}</b> - {_esc(doing)}"
|
||||
elif getattr(self, "_test_end", None) is not None:
|
||||
e = self._test_end
|
||||
key = e.severity
|
||||
text = (f"<b>Test finished: {_esc(e.title)}</b><br>{_esc(e.text)} "
|
||||
"Details: Findings below and the <i>Guided troubleshooting</i> tab.")
|
||||
elif getattr(self, "_test_text", ""):
|
||||
key, text = "warning", f"<b>{_esc(self._test_text)}</b>"
|
||||
else:
|
||||
key = "idle"
|
||||
text = ("Press <b>▶ Test box</b>: the tool restarts the box, checks its start-up (sensors, SD card, "
|
||||
"Wi-Fi, clock) and then whether its sensor data reaches InfluxDB. Takes about 4 minutes.")
|
||||
bg, fg = STRIP[key]
|
||||
self.test_status.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
|
||||
self.test_status.setText(text)
|
||||
|
||||
def matter_payload(self) -> SetupPayload:
|
||||
"""Pairing payload from this box's log (VID/PID/discriminator), defaults otherwise."""
|
||||
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [])
|
||||
"""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:
|
||||
@@ -233,6 +400,39 @@ class SerialTab(QWidget):
|
||||
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:
|
||||
@@ -268,6 +468,7 @@ class SerialTab(QWidget):
|
||||
self._session.stop()
|
||||
self.connect_btn.setText("Disconnect" if running else "Connect")
|
||||
self.reset_btn.setEnabled(state == State.CONNECTED)
|
||||
self.test_btn.setEnabled(state != State.WAITING)
|
||||
self.port_combo.setEnabled(not running)
|
||||
self.all_ports.setEnabled(not running)
|
||||
self.refresh_btn.setEnabled(not running)
|
||||
@@ -289,6 +490,8 @@ class SerialTab(QWidget):
|
||||
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
|
||||
@@ -296,19 +499,24 @@ class SerialTab(QWidget):
|
||||
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 not None and line.kind != LineKind.HOST and not (
|
||||
line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET)
|
||||
):
|
||||
return
|
||||
self._append(line)
|
||||
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()
|
||||
fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
|
||||
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST):
|
||||
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)
|
||||
@@ -320,9 +528,11 @@ class SerialTab(QWidget):
|
||||
|
||||
# ---- diagnostics -------------------------------------------------
|
||||
def _refresh_diag(self) -> None:
|
||||
self._tick_test()
|
||||
if self._diag_dirty:
|
||||
self._diag_dirty = False
|
||||
self.diag.show_report(self._engine.report())
|
||||
self.diag.show_report(self._engine.report(), self._cloud_rows())
|
||||
self._update_test_strip()
|
||||
self.guide.refresh() # countdowns and timeouts need a steady tick
|
||||
|
||||
# ---- guided troubleshooting hooks ------------------------------------
|
||||
@@ -330,8 +540,36 @@ class SerialTab(QWidget):
|
||||
if action == "reset" and self._manager.state == State.CONNECTED:
|
||||
self._manager.reset_board()
|
||||
return True
|
||||
if action == "influx":
|
||||
cfg = self.influx_config()
|
||||
mac = self._engine.mac or (self._active_port.mac if self._active_port else "") or ""
|
||||
if cfg is None:
|
||||
self._runner.note = "InfluxDB access is not set up: Settings > InfluxDB access."
|
||||
return False
|
||||
since = self._test_since
|
||||
self._cloud_checking = True
|
||||
threading.Thread(target=lambda: self._bridge.influx.emit(influx.check_device(cfg, mac, since=since)),
|
||||
name="influx-check", daemon=True).start()
|
||||
return True
|
||||
return False
|
||||
|
||||
def influx_config(self) -> influx.InfluxConfig | None:
|
||||
"""Read token: KOTA_INFLUX_TOKEN, else the tool settings. None when not set up."""
|
||||
token = os.environ.get("KOTA_INFLUX_TOKEN") or str(self._settings.value("influx/token", ""))
|
||||
if not token:
|
||||
return None
|
||||
return influx.InfluxConfig(
|
||||
token=token,
|
||||
url=str(self._settings.value("influx/url", influx.DEFAULT_URL)),
|
||||
org=str(self._settings.value("influx/org", influx.DEFAULT_ORG)),
|
||||
bucket=str(self._settings.value("influx/bucket", influx.DEFAULT_BUCKET)))
|
||||
|
||||
def _on_influx(self, check: influx.InfluxCheck) -> None:
|
||||
self._cloud, self._cloud_checking = check, False
|
||||
self._runner.influx_result(check.outcome, check.summary, check.details)
|
||||
self._diag_dirty = True
|
||||
self.guide.refresh()
|
||||
|
||||
def _guide_log(self, text: str) -> None:
|
||||
# Technician answers go into the session log and the log view.
|
||||
line = host_event(text)
|
||||
@@ -393,11 +631,14 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
settings_menu.addAction("InfluxDB access...", self._edit_influx)
|
||||
|
||||
def _edit_service_base(self) -> None:
|
||||
st = self.serial_tab
|
||||
@@ -410,6 +651,39 @@ class MainWindow(QMainWindow):
|
||||
elif ok:
|
||||
QMessageBox.warning(self, "Service system address", "The address must start with https://")
|
||||
|
||||
def _edit_influx(self) -> None:
|
||||
st = self.serial_tab._settings
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("InfluxDB access")
|
||||
form = QFormLayout(dlg)
|
||||
form.addRow(QLabel("Used by the check 'Sensor data in the database'. Ask the firmware team for a "
|
||||
"<b>read-only</b> token for the bucket.", wordWrap=True))
|
||||
url = QLineEdit(str(st.value("influx/url", influx.DEFAULT_URL)))
|
||||
org = QLineEdit(str(st.value("influx/org", influx.DEFAULT_ORG)))
|
||||
bucket = QLineEdit(str(st.value("influx/bucket", influx.DEFAULT_BUCKET)))
|
||||
token = QLineEdit(echoMode=QLineEdit.EchoMode.Password)
|
||||
token.setPlaceholderText("stored - leave empty to keep" if st.value("influx/token", "")
|
||||
else "paste the read token")
|
||||
clear = QCheckBox("Remove the stored token")
|
||||
for label, w in (("Address", url), ("Organisation", org), ("Bucket", bucket), ("Token", token), ("", clear)):
|
||||
form.addRow(label, w)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(dlg.accept)
|
||||
buttons.rejected.connect(dlg.reject)
|
||||
form.addRow(buttons)
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
if not url.text().strip().startswith("https://"):
|
||||
QMessageBox.warning(self, "InfluxDB access", "The address must start with https://")
|
||||
return
|
||||
st.setValue("influx/url", url.text().strip())
|
||||
st.setValue("influx/org", org.text().strip() or influx.DEFAULT_ORG)
|
||||
st.setValue("influx/bucket", bucket.text().strip() or influx.DEFAULT_BUCKET)
|
||||
if clear.isChecked():
|
||||
st.remove("influx/token")
|
||||
elif token.text().strip():
|
||||
st.setValue("influx/token", token.text().strip())
|
||||
|
||||
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||
self.serial_tab.shutdown()
|
||||
super().closeEvent(event)
|
||||
@@ -55,10 +55,14 @@ class MatterQrWidget(QWidget):
|
||||
self.payload = payload
|
||||
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
|
||||
self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
|
||||
src = ("from this box's log" if payload.from_log else
|
||||
"standard code of the v1.0 firmware - connect the box to confirm")
|
||||
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"Discriminator {payload.discriminator} ({src}). Same as the sticker on the box.")
|
||||
f"{src}. Same as the sticker on the box.")
|
||||
|
||||
|
||||
class MatterQrDialog(QDialog):
|
||||
|
||||
@@ -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()
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
||||
requires-python = ">=3.11,<3.14"
|
||||
dependencies = [
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
# Double-click in Finder, or run: bash run_macos.command
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
# Keep the virtual environment out of the (Google Drive) tool folder.
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.6"
|
||||
# If Finder refuses to open this file (Google Drive drops the executable bit), run it once as:
|
||||
# bash run_macos.command (or: chmod +x run_macos.command)
|
||||
# One shared Python environment for every version (update_macos.command keeps it current).
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
rem Keep the virtual environment out of the (Google Drive) tool folder.
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.6"
|
||||
rem One shared Python environment for every version (update_windows.bat keeps it current).
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
|
||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||
|
||||
where uv >nul 2>nul
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
--- connected to COM14 ---
|
||||
W (9426) wifi:Haven't to connect to a suitable AP now!
|
||||
--- reset issued by tool ---
|
||||
ESP-ROM:esp32s3-20210327
|
||||
Build:Mar 27 2021
|
||||
rst:0x15 (USB_UART_CHIP_RESET),boot:0xb (SPI_FAST_FLASH_BOOT)
|
||||
Saved PC:0x4037f366
|
||||
SPIWP:0xee
|
||||
mode:DIO, clock div:1
|
||||
load:0x3fce2820,len:0x14f0
|
||||
load:0x403c8700,len:0xd24
|
||||
load:0x403cb700,len:0x2f48
|
||||
entry 0x403c8924
|
||||
I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader
|
||||
I (24) boot: compile time Sep 10 2026 13:24:39
|
||||
I (25) boot: Multicore bootloader
|
||||
I (25) boot: chip revision: v0.2
|
||||
I (28) boot: efuse block revision: v1.4
|
||||
I (32) boot.esp32s3: Boot SPI Speed : 80MHz
|
||||
I (35) boot.esp32s3: SPI Mode : DIO
|
||||
I (39) boot.esp32s3: SPI Flash Size : 8MB
|
||||
I (43) boot: Enabling RNG early entropy source...
|
||||
I (48) boot: Partition Table:
|
||||
I (50) boot: ## Label Usage Type ST Offset Length
|
||||
I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000
|
||||
I (63) boot: 1 nvs WiFi data 01 02 00010000 00020000
|
||||
I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000
|
||||
I (76) boot: 3 otadata OTA data 01 00 00031000 00002000
|
||||
I (82) boot: 4 phy_init RF data 01 01 00033000 00001000
|
||||
I (89) boot: 5 ota_0 OTA app 00 10 00040000 00300000
|
||||
I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000
|
||||
I (102) boot: 7 fctry WiFi data 01 02 00640000 00006000
|
||||
I (109) boot: End of partition table
|
||||
I (112) esp_image: segment 0: paddr=00040020 vaddr=3c190020 size=67420h (422944) map
|
||||
I (194) esp_image: segment 1: paddr=000a7448 vaddr=3fc9c700 size=05d00h ( 23808) load
|
||||
I (200) esp_image: segment 2: paddr=000ad150 vaddr=40374000 size=02ec8h ( 11976) load
|
||||
I (203) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=187370h (1602416) map
|
||||
I (488) esp_image: segment 4: paddr=00237398 vaddr=40376ec8 size=1580ch ( 88076) load
|
||||
I (507) esp_image: segment 5: paddr=0024cbac vaddr=5000002c size=00020h ( 32) load
|
||||
I (517) boot: Loaded app from partition at offset 0x40000
|
||||
I (518) boot: Disabling RNG early entropy source...
|
||||
I (528) octal_psram: vendor id : 0x0d (AP)
|
||||
I (528) octal_psram: dev id : 0x02 (generation 3)
|
||||
I (529) octal_psram: density : 0x03 (64 Mbit)
|
||||
I (530) octal_psram: good-die : 0x01 (Pass)
|
||||
I (535) octal_psram: Latency : 0x01 (Fixed)
|
||||
I (539) octal_psram: VCC : 0x01 (3V)
|
||||
I (543) octal_psram: SRF : 0x01 (Fast Refresh)
|
||||
I (548) octal_psram: BurstType : 0x01 (Hybrid Wrap)
|
||||
I (553) octal_psram: BurstLen : 0x01 (32 Byte)
|
||||
I (557) octal_psram: Readlatency : 0x02 (10 cycles@Fixed)
|
||||
I (562) octal_psram: DriveStrength: 0x00 (1/1)
|
||||
I (567) esp_psram: Found 8MB PSRAM device
|
||||
I (570) esp_psram: Speed: 40MHz
|
||||
I (573) cpu_start: Multicore app
|
||||
I (1307) esp_psram: SPI SRAM memory test OK
|
||||
I (1315) cpu_start: GPIO 44 and 43 are used as console UART I/O pins
|
||||
I (1316) cpu_start: Pro cpu start user code
|
||||
I (1316) cpu_start: cpu freq: 160000000 Hz
|
||||
I (1318) app_init: Application information:
|
||||
I (1322) app_init: Project name: kota-sensorboard
|
||||
I (1327) app_init: App version: 1.0.16
|
||||
I (1331) app_init: Compile time: Sep 28 2026 15:07:57
|
||||
I (1336) app_init: ELF file SHA256: 3ae273ec1...
|
||||
I (1340) app_init: ESP-IDF: v5.5.3-dirty
|
||||
I (1345) efuse_init: Min chip rev: v0.0
|
||||
I (1348) efuse_init: Max chip rev: v0.99
|
||||
I (1353) efuse_init: Chip rev: v0.2
|
||||
I (1357) heap_init: Initializing. RAM available for dynamic allocation:
|
||||
I (1363) heap_init: At 3FCB4AD0 len 00034C40 (211 KiB): RAM
|
||||
I (1368) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM
|
||||
I (1373) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM
|
||||
I (1379) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM
|
||||
I (1384) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator
|
||||
I (1392) spi_flash: detected chip: generic
|
||||
I (1394) spi_flash: flash io: dio
|
||||
I (1400) sleep_gpio: Configure to isolate all GPIO pins in sleep state
|
||||
I (1404) sleep_gpio: Enable automatic switching of GPIO sleep configuration
|
||||
I (1419) main_task: Started on CPU0
|
||||
I (1422) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations
|
||||
I (1430) main_task: Calling app_main()
|
||||
E (1433) ID: [E-MAIN-04] Device MAC address: 14:C1:9F:57:72:48
|
||||
I (1475) KOTA: led color=green pattern=solid
|
||||
I (1676) I2C: Creating I2C mutex
|
||||
I (1694) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688
|
||||
I (1696) OTA_CLIENT: OTA token loaded from NVS: <REDACTED>
|
||||
I (1698) OTA_CLIENT: Device is not in selfcheck mode
|
||||
I (1702) OTA_CLIENT: Update metadata loaded from NVS: version=15, status=2
|
||||
I (1707) OTA_CLIENT: Device is in factory state
|
||||
I (1711) SPI: Creating SPI mutex
|
||||
I (1755) SD_CARD: SD card mounted successfully
|
||||
I (1756) SD_CARD: /sdcard exists, SD card initialized successfully
|
||||
I (1758) INFLUX_BUFFER: Buffer file does not exist, returning count of 0
|
||||
I (1763) KOTA: sd type=SDHC size=29.2GB fs=FAT32 free=29.1GB env_keys=INFLUX_TOKEN,INFLUX_URL buffer_lines=0
|
||||
I (1774) WIFI_CREDENTIALS: SSID/PASSWORD not set in .env; Matter will be used for WiFi provisioning
|
||||
I (1785) MAIN: Matter node created successfully
|
||||
W (1787) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1793) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1802) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1811) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1819) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1828) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1837) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1846) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1854) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1863) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1872) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1881) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1890) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1898) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1907) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1916) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1925) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1933) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
I (1944) pp: pp rom version: e7ae62f
|
||||
I (1945) net80211: net80211 rom version: e7ae62f
|
||||
I (1951) wifi:wifi driver task: 3fcc9610, prio:23, stack:6656, core=0
|
||||
I (1969) wifi:wifi firmware version: 4df78f2
|
||||
I (1970) wifi:wifi certification version: v7.0
|
||||
I (1971) wifi:config NVS flash: enabled
|
||||
I (1971) wifi:config nano formatting: disabled
|
||||
I (1975) wifi:Init data frame dynamic rx buffer num: 32
|
||||
I (1980) wifi:Init static rx mgmt buffer num: 5
|
||||
I (1984) wifi:Init management short buffer num: 32
|
||||
I (1988) wifi:Init dynamic tx buffer num: 32
|
||||
I (1992) wifi:Init static tx FG buffer num: 2
|
||||
I (1997) wifi:Init static rx buffer size: 1600
|
||||
I (2001) wifi:Init static rx buffer num: 10
|
||||
I (2004) wifi:Init dynamic rx buffer num: 32
|
||||
I (2009) wifi_init: rx ba win: 6
|
||||
I (2011) wifi_init: accept mbox: 6
|
||||
I (2014) wifi_init: tcpip mbox: 32
|
||||
I (2018) wifi_init: udp mbox: 6
|
||||
I (2020) wifi_init: tcp mbox: 6
|
||||
I (2023) wifi_init: tcp tx win: 5760
|
||||
I (2027) wifi_init: tcp rx win: 5760
|
||||
I (2030) wifi_init: tcp mss: 1440
|
||||
I (2045) chip[DL]: NVS set: chip-counters/reboot-count = 13 (0xD)
|
||||
I (2048) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC)
|
||||
I (2050) BLE_INIT: BT controller compile version [1bb2f50]
|
||||
I (2053) BLE_INIT: Using main XTAL as clock source
|
||||
I (2058) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1
|
||||
I (2067) BLE_INIT: Bluetooth MAC: 14:c1:9f:57:72:4a
|
||||
I (2072) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10
|
||||
I (2119) NimBLE: GAP procedure initiated: stop advertising.
|
||||
|
||||
I (2122) CHIP[DL]: BLE host-controller synced
|
||||
I (2623) chip[DL]: Starting ESP WiFi layer
|
||||
I (2626) wifi:mode : sta (14:c1:9f:57:72:48)
|
||||
I (2627) wifi:enable tsf
|
||||
W (2628) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2629) chip[DL]:
|
||||
I (2629) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43Attempting to connect WiFi station interface
|
||||
|
||||
I (2641) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
I (2642) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2
|
||||
I (2647) chip[DL]:
|
||||
Done driving station state, nothing else to do...
|
||||
I (2659) chip[SVR]: Initializing subscription resumption storage...
|
||||
I (2666) chip[SVR]: Server initializing...
|
||||
I (2669) chip[TS]: Last Known Good Time: 2026-09-29T14:59:23
|
||||
I (2674) chip[DMG]: AccessControl: initializing
|
||||
I (2678) chip[DMG]: Examples::AccessControlDelegate::Init
|
||||
I (2683) chip[DMG]: AccessControl: setting
|
||||
I (2687) chip[DMG]: DefaultAclStorage: initializing
|
||||
I (2691) chip[DMG]: DefaultAclStorage: 0 entries loaded
|
||||
I (2696) chip[SVR]: WARNING: mTestEventTriggerDelegate is null
|
||||
I (2715) chip[ZCL]: Using ZAP configuration...
|
||||
I (2716) esp_matter_cluster: Cluster plugin init common callback
|
||||
I (2718) chip[DMG]: AccessControlCluster: initializing
|
||||
I (2720) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported
|
||||
I (2730) chip[DIS]: Updating services using commissioning mode 1
|
||||
I (2733) chip[DIS]: CHIP minimal mDNS started advertising.
|
||||
I (2739) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
|
||||
I (2749) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: 995B80B34BC8AAD9.
|
||||
I (2758) chip[DIS]: mDNS service published: _matterc._udp
|
||||
I (2763) chip[IN]: CASE Server enabling CASE session setups
|
||||
I (2769) chip[SVR]: Joining Multicast groups
|
||||
I (2772) chip[SVR]: Server Listening...
|
||||
I (2778) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 **********
|
||||
I (2788) data_model: Dynamic endpoint 0 added
|
||||
E (2790) data_model: Cluster cannot be NULL.
|
||||
I (2795) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 **********
|
||||
I (2806) data_model: Dynamic endpoint 1 added
|
||||
E (2809) data_model: Cluster cannot be NULL.
|
||||
I (2815) chip[DL]:
|
||||
I (2815) MAIN: ESP-Matter started successfully
|
||||
WIFI_EVENT_STA_START
|
||||
W (2821) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2826) chip[DL]: Attempting to connect WiFi station interface
|
||||
E (2832) wifi:sta is connecting, return error
|
||||
E (2836) chip[DL]: esp_wifi_connect() failed: ESP_ERR_WIFI_CONN
|
||||
I (2842) KOTA: boot fw=1.0.16 build=3ae273ec hw=2 mac=14C19F577248 reset=USB_UART_CHIP_RESET mode=matter commissioned=0 last_restart=none
|
||||
I (2843) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable)
|
||||
I (2861) NimBLE: GAP procedure initiated: advertise;
|
||||
I (2865) NimBLE: disc_mode=2
|
||||
I (2867) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40
|
||||
I (2877) NimBLE:
|
||||
|
||||
I (2880) chip[DL]: CHIPoBLE advertising started
|
||||
I (2883) MAIN: Commissioning window opened
|
||||
I (2888) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332
|
||||
I (2896) KOTA: led color=cyan pattern=breathe period=3000ms
|
||||
I (2901) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332
|
||||
W (2909) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2908) KOTA: health up=1s status=OK wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=0 heap=117491 psram=7.9M last_upload=never ota=registered
|
||||
W (3401) wifi:Haven't to connect to a suitable AP now!
|
||||
I (3910) KOTA: rtc running=0 time=2000-01-01T00:00:00
|
||||
W (4402) wifi:Haven't to connect to a suitable AP now!
|
||||
W (5403) wifi:Haven't to connect to a suitable AP now!
|
||||
W (6404) wifi:Haven't to connect to a suitable AP now!
|
||||
I (7219) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (7222) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (7223) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (7226) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (7233) wifi:Haven't to connect to a suitable AP now!
|
||||
E (7238) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (7248) wifi:Haven't to connect to a suitable AP now!
|
||||
I (7252) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (7259) wifi:Haven't to connect to a suitable AP now!
|
||||
E (7264) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (7273) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (7278) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (7379) wifi:Haven't to connect to a suitable AP now!
|
||||
I (7380) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (7383) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (7386) wifi:Haven't to connect to a suitable AP now!
|
||||
E (7391) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (7400) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (7405) wifi:Haven't to connect to a suitable AP now!
|
||||
W (8411) wifi:Haven't to connect to a suitable AP now!
|
||||
W (9415) wifi:Haven't to connect to a suitable AP now!
|
||||
W (10417) wifi:Haven't to connect to a suitable AP now!
|
||||
W (11419) wifi:Haven't to connect to a suitable AP now!
|
||||
I (12201) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (12204) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (12205) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (12208) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (12215) wifi:Haven't to connect to a suitable AP now!
|
||||
E (12220) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (12230) wifi:Haven't to connect to a suitable AP now!
|
||||
I (12234) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (12242) wifi:Haven't to connect to a suitable AP now!
|
||||
E (12247) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (12256) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (12261) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (12361) wifi:Haven't to connect to a suitable AP now!
|
||||
I (12362) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (12366) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (12368) wifi:Haven't to connect to a suitable AP now!
|
||||
E (12373) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (12383) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (12427) wifi:Haven't to connect to a suitable AP now!
|
||||
W (13428) wifi:Haven't to connect to a suitable AP now!
|
||||
I (13912) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
|
||||
I (13917) INFLUX_BUFFER: Buffer file does not exist, returning count of 0
|
||||
W (13920) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
|
||||
I (13943) BME688: Delaying for 10000 microseconds
|
||||
W (14430) wifi:Haven't to connect to a suitable AP now!
|
||||
I (14563) KOTA: mic enabled=1 (GPIO3=0, expected 0)
|
||||
I (14566) Flicker: Flicker sensor initialized on pin 14
|
||||
I (14567) temperature_sensor: Range [20°C ~ 100°C], error < 2°C
|
||||
I (14569) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data
|
||||
W (14573) wifi:
|
||||
I (14569) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data
|
||||
Haven't to connect to a suitable AP now!
|
||||
I (14570) SEN66: Creating sensor mutex
|
||||
I (14572) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms
|
||||
I (14600) Flicker: Starting to collect samples in gather task
|
||||
I (14585) DeviceStatus: Active tasks: 27
|
||||
I (14588) SEN66: Waking up sensor by setting power GPIO to enabled level...
|
||||
I (14570) INFLUX_BUFFER: Buffer file does not exist, returning empty buffer
|
||||
I (14622) INFLUX_BUFFER: Buffer is empty, nothing to flush
|
||||
I (14613) DeviceStatus: Task list:
|
||||
gather_sensor_t R 1 1920 25
|
||||
gather_sensor_t X 1 1368 23
|
||||
IDLE1 R 0 700 6
|
||||
IDLE0 R 0 692 5
|
||||
gather_sensor_t B 1 2556 22
|
||||
reset_task B 0 3356 10
|
||||
ds1307_update B 1 776 18
|
||||
tiT B 18 2420 12
|
||||
gather_task B 1 1916 20
|
||||
gather_task B 1 14288 21
|
||||
ota_task B 1 7516 19
|
||||
CHIP B 1 3700 14
|
||||
health B 1 728 17
|
||||
Tmr Svc B 1 1364 7
|
||||
led_task B 5 868 8
|
||||
nimble_host B 21 1348 16
|
||||
ipc0 S 24 372 1
|
||||
ipc1 S 24 524 2
|
||||
sys_evt B 20 2676 11
|
||||
gather_sensor_t B 1 3180 26
|
||||
esp_timer S 22 3044 3
|
||||
gather_sensor_t B 1 2324 24
|
||||
main B 1 892 4
|
||||
gather_sensor_t S 1 1480 27
|
||||
wifi B 23 3796 13
|
||||
btController B 23 1864 15
|
||||
flicker_gather_ S 24 1988 28
|
||||
usb_console B 1 2840 9
|
||||
|
||||
W (14702) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (15433) wifi:Haven't to connect to a suitable AP now!
|
||||
I (15630) Flicker: Finished collecting samples, notifying main task
|
||||
I (15632) Flicker: Collected 1024 samples for dominant frequency analysis
|
||||
I (15639) SENSOR_GATHERER: Sensor should now be asleep
|
||||
W (15650) AS7341: Reading spectral data from AS7341
|
||||
I (15659) BME688: Delaying for 199517 microseconds
|
||||
W (15710) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (15958) AS7341: Reading basic counts data from AS7341
|
||||
W (15974) AS7341: Reading flicker detection state from AS7341
|
||||
W (16194) as7341: FD Status Register: 0x20 (0b00100000)
|
||||
I (16201) AS7341: Updating Matter attributes with AS7341 data: 10474
|
||||
I (16202) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 **********
|
||||
I (16208) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 10474 **********
|
||||
E (16219) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
|
||||
E (16229) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
|
||||
W (16238) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
I (16241) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null **********
|
||||
I (16256) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1021 **********
|
||||
E (16267) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
|
||||
E (16276) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
|
||||
W (16286) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (16434) wifi:Haven't to connect to a suitable AP now!
|
||||
W (16711) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (17183) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (17186) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (17187) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (17190) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (17198) wifi:Haven't to connect to a suitable AP now!
|
||||
E (17203) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (17212) wifi:Haven't to connect to a suitable AP now!
|
||||
I (17217) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (17224) wifi:Haven't to connect to a suitable AP now!
|
||||
E (17229) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (17239) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (17243) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (17244) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (17291) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (17344) wifi:Haven't to connect to a suitable AP now!
|
||||
I (17346) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (17349) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (17351) wifi:Haven't to connect to a suitable AP now!
|
||||
E (17356) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (17366) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (17437) wifi:Haven't to connect to a suitable AP now!
|
||||
W (17712) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (18256) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (18292) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (18439) wifi:Haven't to connect to a suitable AP now!
|
||||
W (18713) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (19257) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (19293) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (19440) wifi:Haven't to connect to a suitable AP now!
|
||||
I (19615) SEN66: Sent start measurement command, waiting for sensor to be ready...
|
||||
W (19714) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (19928) KOTA: cmd ping ok=1 fw=1.0.16 api=1
|
||||
I (19933) KOTA: cmd wifi_get ok=1 ssid=- password_set=0 mode=matter
|
||||
W (20258) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (20294) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (20441) wifi:Haven't to connect to a suitable AP now!
|
||||
W (20715) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (21259) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (21295) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (21442) wifi:Haven't to connect to a suitable AP now!
|
||||
W (21716) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (22166) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (22169) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (22170) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (22173) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (22181) wifi:Haven't to connect to a suitable AP now!
|
||||
E (22186) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (22196) wifi:Haven't to connect to a suitable AP now!
|
||||
I (22200) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (22207) wifi:Haven't to connect to a suitable AP now!
|
||||
E (22212) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (22222) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (22226) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (22260) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (22296) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (22327) wifi:Haven't to connect to a suitable AP now!
|
||||
I (22328) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (22332) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (22334) wifi:Haven't to connect to a suitable AP now!
|
||||
E (22340) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (22349) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (22446) wifi:Haven't to connect to a suitable AP now!
|
||||
I (22574) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (22717) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (23075) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (23261) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (23297) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (23447) wifi:Haven't to connect to a suitable AP now!
|
||||
I (23576) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (23718) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (24077) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (24262) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (24299) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (24448) wifi:Haven't to connect to a suitable AP now!
|
||||
I (24570) OTA_CLIENT: HTTP client handle: 0x3fcf02e4
|
||||
I (24578) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (24710) MICROPHONE: Read 600000 bytes in 9940 ms
|
||||
I (24712) MICROPHONE: Read 600000 bytes of audio data
|
||||
W (24719) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (25079) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (25264) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (25300) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (25449) wifi:Haven't to connect to a suitable AP now!
|
||||
I (25580) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (25720) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (26081) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (26265) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (26301) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (26450) wifi:Haven't to connect to a suitable AP now!
|
||||
I (26582) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (26721) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (27083) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (27150) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (27152) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (27154) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (27157) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (27164) wifi:Haven't to connect to a suitable AP now!
|
||||
E (27169) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (27179) wifi:Haven't to connect to a suitable AP now!
|
||||
I (27183) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (27190) wifi:Haven't to connect to a suitable AP now!
|
||||
E (27196) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (27205) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (27209) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (27266) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (27302) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (27310) wifi:Haven't to connect to a suitable AP now!
|
||||
I (27311) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (27315) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (27318) wifi:Haven't to connect to a suitable AP now!
|
||||
E (27323) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (27332) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (27456) wifi:Haven't to connect to a suitable AP now!
|
||||
I (27584) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (27722) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (28085) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (28160) MICROPHONE: Raw block stats: mean=-17.8 min=-146018 max=134018
|
||||
I (28161) MICROPHONE: Sum SQ: 0.578925, RMS pressure: 0.001701, Normalized: 0.001701, dB peak: -55.38
|
||||
W (28166) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (28267) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (28303) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (28457) wifi:Haven't to connect to a suitable AP now!
|
||||
I (28586) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (28723) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (29087) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (29172) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (29268) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (29304) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (29458) wifi:Haven't to connect to a suitable AP now!
|
||||
I (29588) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (29724) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (30089) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (30173) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (30269) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (30305) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (30459) wifi:Haven't to connect to a suitable AP now!
|
||||
I (30590) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (30725) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (31091) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (31174) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (31270) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (31306) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (31460) wifi:Haven't to connect to a suitable AP now!
|
||||
E (31572) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0
|
||||
E (31573) esp-tls: Failed to open new connection
|
||||
E (31575) transport_base: Failed to open a new connection
|
||||
E (31580) HTTP_CLIENT: Connection failed, sock < 0
|
||||
E (31585) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:355
|
||||
I (31592) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
E (31593) OTA_CLIENT: [E-OTA-13] Failed to check for updates: ESP_ERR_HTTP_CONNECT
|
||||
I (31606) KOTA: status OK -> CONNECTION_ERROR reason=ota_check_failed
|
||||
W (31726) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (32098) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (32133) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (32135) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (32137) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (32140) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (32147) wifi:Haven't to connect to a suitable AP now!
|
||||
E (32152) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (32162) wifi:Haven't to connect to a suitable AP now!
|
||||
I (32166) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (32173) wifi:Haven't to connect to a suitable AP now!
|
||||
W (32175) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
E (32179) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (32194) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (32199) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (32271) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (32300) wifi:Haven't to connect to a suitable AP now!
|
||||
I (32301) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (32305) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (32307) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (32307) wifi:Haven't to connect to a suitable AP now!
|
||||
E (32319) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (32328) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (32462) wifi:Haven't to connect to a suitable AP now!
|
||||
I (32599) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (32727) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (32730) chip[DL]: bleAdv Timeout : Start slow advertisement
|
||||
I (32732) chip[DL]: Configuring CHIPoBLE advertising (interval 500 ms, connectable)
|
||||
I (32737) chip[DL]: Device already advertising, stop active advertisement and restart
|
||||
I (32744) NimBLE: GAP procedure initiated: stop advertising.
|
||||
|
||||
I (32750) NimBLE: GAP procedure initiated: advertise;
|
||||
I (32754) NimBLE: disc_mode=2
|
||||
I (32756) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=800 adv_itvl_max=800
|
||||
I (32767) NimBLE:
|
||||
|
||||
I (33100) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (33184) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (33272) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (33320) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (33463) wifi:Haven't to connect to a suitable AP now!
|
||||
I (33601) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (33728) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (34102) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (34185) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (34274) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (34321) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (34464) wifi:Haven't to connect to a suitable AP now!
|
||||
I (34603) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (34729) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (35104) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (35186) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (35275) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (35322) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (35465) wifi:Haven't to connect to a suitable AP now!
|
||||
I (35605) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (35730) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (36106) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (36187) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (36276) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (36323) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (36466) wifi:Haven't to connect to a suitable AP now!
|
||||
I (36607) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (36731) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (37107) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (37122) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (37125) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (37126) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (37129) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (37137) wifi:Haven't to connect to a suitable AP now!
|
||||
E (37142) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (37151) wifi:Haven't to connect to a suitable AP now!
|
||||
I (37156) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (37163) wifi:Haven't to connect to a suitable AP now!
|
||||
E (37168) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (37178) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (37182) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (37188) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (37277) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (37283) wifi:Haven't to connect to a suitable AP now!
|
||||
I (37284) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (37287) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (37290) wifi:Haven't to connect to a suitable AP now!
|
||||
E (37295) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (37305) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (37324) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (37468) wifi:Haven't to connect to a suitable AP now!
|
||||
I (37608) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (37732) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (38109) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (38195) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (38278) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (38325) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (38470) wifi:Haven't to connect to a suitable AP now!
|
||||
I (38610) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (38733) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (39111) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (39196) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (39279) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (39326) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (39471) wifi:Haven't to connect to a suitable AP now!
|
||||
I (39612) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (39736) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (40113) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (40197) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (40280) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (40326) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (40473) wifi:Haven't to connect to a suitable AP now!
|
||||
I (40614) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (40737) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (41115) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (41198) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (41281) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (41327) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (41474) wifi:Haven't to connect to a suitable AP now!
|
||||
I (41616) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (41738) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (42105) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (42108) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (42109) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (42112) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
I (42116) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (42120) wifi:Haven't to connect to a suitable AP now!
|
||||
E (42130) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (42140) wifi:Haven't to connect to a suitable AP now!
|
||||
I (42144) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (42151) wifi:Haven't to connect to a suitable AP now!
|
||||
E (42156) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (42166) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (42170) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (42199) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (42271) wifi:Haven't to connect to a suitable AP now!
|
||||
I (42272) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (42275) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (42278) wifi:Haven't to connect to a suitable AP now!
|
||||
W (42283) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
E (42283) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (42299) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (42328) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (42476) wifi:Haven't to connect to a suitable AP now!
|
||||
I (42625) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (42739) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (43126) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (43200) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (43290) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (43329) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (43482) wifi:Haven't to connect to a suitable AP now!
|
||||
I (43627) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (43740) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (44128) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (44201) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (44291) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (44330) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (44486) wifi:Haven't to connect to a suitable AP now!
|
||||
I (44629) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (44741) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (45130) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (45202) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (45292) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (45331) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (45487) wifi:Haven't to connect to a suitable AP now!
|
||||
I (45631) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (45742) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (46132) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (46203) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (46293) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (46332) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (46488) wifi:Haven't to connect to a suitable AP now!
|
||||
I (46633) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (46743) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (47093) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (47095) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (47097) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (47100) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (47107) wifi:Haven't to connect to a suitable AP now!
|
||||
E (47112) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (47122) wifi:Haven't to connect to a suitable AP now!
|
||||
I (47126) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
I (47134) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (47134) wifi:Haven't to connect to a suitable AP now!
|
||||
E (47144) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (47154) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (47158) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (47204) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (47259) wifi:Haven't to connect to a suitable AP now!
|
||||
I (47260) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (47263) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (47266) wifi:Haven't to connect to a suitable AP now!
|
||||
E (47271) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (47281) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (47294) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (47333) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (47493) wifi:Haven't to connect to a suitable AP now!
|
||||
I (47639) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (47744) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (48140) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (48205) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (48295) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (48334) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (48495) wifi:Haven't to connect to a suitable AP now!
|
||||
I (48641) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (48747) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (49142) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (49206) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (49296) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (49335) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (49496) wifi:Haven't to connect to a suitable AP now!
|
||||
I (49643) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (49748) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (50144) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (50207) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (50297) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (50336) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (50498) wifi:Haven't to connect to a suitable AP now!
|
||||
I (50645) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (50749) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (51146) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (51208) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (51298) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (51337) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (51500) wifi:Haven't to connect to a suitable AP now!
|
||||
I (51647) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (51750) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (52081) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (52084) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (52085) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (52088) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (52096) wifi:Haven't to connect to a suitable AP now!
|
||||
E (52100) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (52110) wifi:Haven't to connect to a suitable AP now!
|
||||
I (52115) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (52122) wifi:Haven't to connect to a suitable AP now!
|
||||
E (52127) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (52136) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (52141) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (52148) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (52209) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (52242) wifi:Haven't to connect to a suitable AP now!
|
||||
I (52243) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (52246) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (52249) wifi:Haven't to connect to a suitable AP now!
|
||||
E (52254) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (52264) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (52299) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (52338) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (52508) wifi:Haven't to connect to a suitable AP now!
|
||||
I (52653) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (52751) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (53154) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (53210) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (53300) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (53339) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (53509) wifi:Haven't to connect to a suitable AP now!
|
||||
I (53655) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (53752) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (54156) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (54212) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (54301) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (54340) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (54510) wifi:Haven't to connect to a suitable AP now!
|
||||
I (54657) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (54753) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (55158) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (55213) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (55302) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (55341) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (55511) wifi:Haven't to connect to a suitable AP now!
|
||||
I (55659) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (55754) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (56160) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (56214) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (56303) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (56342) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (56512) wifi:Haven't to connect to a suitable AP now!
|
||||
I (56661) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (56755) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (57064) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (57067) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (57068) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (57071) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (57079) wifi:Haven't to connect to a suitable AP now!
|
||||
E (57083) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (57093) wifi:Haven't to connect to a suitable AP now!
|
||||
I (57097) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (57105) wifi:Haven't to connect to a suitable AP now!
|
||||
E (57110) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (57119) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (57124) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (57162) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (57215) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (57225) wifi:Haven't to connect to a suitable AP now!
|
||||
I (57226) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (57230) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (57232) wifi:Haven't to connect to a suitable AP now!
|
||||
E (57237) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (57247) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (57304) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (57344) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (57517) wifi:Haven't to connect to a suitable AP now!
|
||||
I (57663) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (57756) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (58164) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (58216) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (58305) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (58345) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (58518) wifi:Haven't to connect to a suitable AP now!
|
||||
I (58665) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (58757) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (59166) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (59217) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (59306) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (59346) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (59519) wifi:Haven't to connect to a suitable AP now!
|
||||
I (59667) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (59758) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (60168) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (60218) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (60307) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (60346) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (60520) wifi:Haven't to connect to a suitable AP now!
|
||||
I (60669) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (60759) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (61170) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (61219) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (61308) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (61347) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (61521) wifi:Haven't to connect to a suitable AP now!
|
||||
I (61671) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (61760) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (62047) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (62050) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (62051) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (62054) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (62062) wifi:Haven't to connect to a suitable AP now!
|
||||
E (62067) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (62076) wifi:Haven't to connect to a suitable AP now!
|
||||
I (62081) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (62088) wifi:Haven't to connect to a suitable AP now!
|
||||
E (62093) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (62102) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (62107) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (62172) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (62208) wifi:Haven't to connect to a suitable AP now!
|
||||
I (62209) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (62212) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (62215) wifi:Haven't to connect to a suitable AP now!
|
||||
W (62220) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
E (62220) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (62236) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (62309) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (62348) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (62527) wifi:Haven't to connect to a suitable AP now!
|
||||
I (62673) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (62761) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (62925) wifi:Haven't to connect to a suitable AP now!
|
||||
I (62925) KOTA: health up=61s status=CONNECTION_ERROR wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=0 heap=47259 psram=7.3M last_upload=never ota=registered
|
||||
I (63174) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (63227) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (63310) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (63349) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (63528) wifi:Haven't to connect to a suitable AP now!
|
||||
I (63675) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (63762) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (64176) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (64228) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (64311) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (64350) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (64529) wifi:Haven't to connect to a suitable AP now!
|
||||
I (64617) SEN66: First measurement, waiting additional 120 seconds for sensor to stabilize...
|
||||
I (64677) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (64763) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (65178) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (65229) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (65312) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (65351) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (65530) wifi:Haven't to connect to a suitable AP now!
|
||||
I (65679) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (65764) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (66180) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (66230) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (66313) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (66352) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (66531) wifi:Haven't to connect to a suitable AP now!
|
||||
I (66681) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (66765) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (67030) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (67033) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (67034) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (67037) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (67045) wifi:Haven't to connect to a suitable AP now!
|
||||
E (67049) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (67059) wifi:Haven't to connect to a suitable AP now!
|
||||
I (67063) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (67071) wifi:Haven't to connect to a suitable AP now!
|
||||
E (67076) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (67085) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (67090) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (67182) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (67191) wifi:Haven't to connect to a suitable AP now!
|
||||
I (67192) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (67195) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (67198) wifi:Haven't to connect to a suitable AP now!
|
||||
E (67203) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (67213) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (67231) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (67314) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (67353) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (67537) wifi:Haven't to connect to a suitable AP now!
|
||||
I (67683) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (67766) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (68184) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (68232) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (68315) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (68354) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (68542) wifi:Haven't to connect to a suitable AP now!
|
||||
I (68685) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (68767) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (69186) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (69233) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (69316) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (69355) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (69546) wifi:Haven't to connect to a suitable AP now!
|
||||
I (69687) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (69768) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (70188) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (70234) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (70317) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (70356) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (70547) wifi:Haven't to connect to a suitable AP now!
|
||||
I (70689) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (70769) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (71190) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (71235) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (71318) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (71357) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (71548) wifi:Haven't to connect to a suitable AP now!
|
||||
I (71691) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (71770) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (72013) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
|
||||
|
||||
I (72015) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
|
||||
I (72017) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
|
||||
I (72020) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
|
||||
W (72027) wifi:Haven't to connect to a suitable AP now!
|
||||
E (72032) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
W (72042) wifi:Haven't to connect to a suitable AP now!
|
||||
I (72046) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
|
||||
W (72054) wifi:Haven't to connect to a suitable AP now!
|
||||
E (72059) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (72068) chip[DL]: Next WiFi station reconnect in 100 ms
|
||||
I (72073) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (72173) wifi:Haven't to connect to a suitable AP now!
|
||||
I (72175) chip[DL]: Attempting to connect WiFi station interface
|
||||
I (72178) chip[DL]: WiFi station state change: NotConnected -> Connecting
|
||||
W (72181) wifi:Haven't to connect to a suitable AP now!
|
||||
E (72185) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
|
||||
I (72191) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
I (72195) chip[DL]: Done driving station state, nothing else to do...
|
||||
W (72236) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (72319) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (72358) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (72552) wifi:Haven't to connect to a suitable AP now!
|
||||
I (72700) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (72771) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (73201) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (73237) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (73320) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (73359) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (73553) wifi:Haven't to connect to a suitable AP now!
|
||||
I (73702) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (73772) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (74203) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (74238) SEN66: RTC is uninitialized, using current time as timestamp
|
||||
W (74321) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (74360) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (74555) wifi:Haven't to connect to a suitable AP now!
|
||||
I (74581) SENSOR_GATHERER: 3/4 tasks still running, waiting...
|
||||
I (74627) INFLUX_BUFFER: Buffer file does not exist, returning empty buffer
|
||||
I (74628) INFLUX_BUFFER: Buffer is empty, nothing to flush
|
||||
I (74704) SENSOR_GATHERER: 2/2 tasks still running, waiting...
|
||||
W (74773) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
--- disconnected ---
|
||||
@@ -0,0 +1,22 @@
|
||||
ESP-ROM:esp32s3-20210327
|
||||
rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)
|
||||
I (1299) app_init: Project name: kota-sensorboard
|
||||
I (1303) app_init: App version: 1.0.16
|
||||
I (1307) app_init: Compile time: Sep 28 2026 15:07:57
|
||||
I (1317) app_init: ELF file SHA256: 4f10989e2...
|
||||
E (1402) ID: [E-MAIN-04] Device MAC address: A4:CB:8F:C9:46:F8
|
||||
I (1480) KOTA: led previous_boot status=CONNECTION_ERROR code=1
|
||||
I (1481) KOTA: led color=purple pattern=blink period=1000ms
|
||||
I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688
|
||||
I (1795) KOTA: rtc running=1 time=2026-09-28T15:20:01
|
||||
I (1850) SD_CARD: SD card mounted successfully
|
||||
I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=0
|
||||
I (2300) MAIN: ESP-Matter started successfully
|
||||
I (2310) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET mode=matter commissioned=1 last_restart=none
|
||||
I (2315) KOTA: matter fabrics=1 window=closed qr=MT:Y.K9042C00KA0648G00 manual=34970112332
|
||||
I (2400) KOTA: mic enabled=1 (GPIO3=0, expected 0)
|
||||
I (4481) KOTA: led color=green pattern=solid
|
||||
I (5200) WIFI_SERVICE: Got IP address
|
||||
I (6100) SNTP: Time synchronized: Mon Sep 28 15:20:06 2026
|
||||
I (8100) KOTA: rtc verify running=1 delta=2s ok=1
|
||||
I (60010) KOTA: health up=60s status=OK wifi=connected rssi=-58 time_valid=1 sd=ok buffer=0 heap=143220 psram=7.6M last_upload=14s ota=registered
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Wi-Fi over USB (firmware usb_console api=1): encoding, replies and the whole
|
||||
write/clear/read job, driven with reply lines in the firmware's exact format."""
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.core.logline import parse_line
|
||||
from kota_tech.diag.engine import DiagnosticsEngine
|
||||
from kota_tech.serialio.commands import (
|
||||
CONNECT_TIMEOUT_S, PING_TIMEOUT_S, Step, WifiFlow, loggable, parse_reply, validate_wifi, wifi_set_command)
|
||||
|
||||
PING = "I (5000) KOTA: cmd ping ok=1 fw=1.0.16 api=1"
|
||||
BOOT = ("I (2843) KOTA: boot fw=1.0.16 build=3ae273ec hw=2 mac=A4CB8FC946F8 reset=RTC_SW_SYS_RST "
|
||||
"mode={mode} commissioned=- last_restart={last}")
|
||||
HEALTH = ("I (9000) KOTA: cmd health ok=1 up=9s status=OK wifi={wifi} rssi={rssi} time_valid=1 sd=ok buffer=0 "
|
||||
"heap=90000 psram=7.6M last_upload=never ota=registered")
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 0.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def make(action, **kw):
|
||||
sent, events, clock = [], [], Clock()
|
||||
flow = WifiFlow(action, lambda c: sent.append(c) or True, events.append, now=clock, **kw)
|
||||
return flow, sent, events, clock
|
||||
|
||||
|
||||
def feed(flow, text):
|
||||
flow.feed(parse_line(text))
|
||||
|
||||
|
||||
# ------------------------------------------------------------- encoding
|
||||
|
||||
def test_wifi_set_command_is_hex_utf8():
|
||||
assert wifi_set_command("Biuro 2.4", "tajne=haslo1") == \
|
||||
"kota wifi set 426975726f20322e34 74616a6e653d6861736c6f31"
|
||||
assert wifi_set_command("Łąka", "zażółć1234").split()[3] == "c581c4856b61"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ssid,password,problem", [
|
||||
("", "12345678", "network name"),
|
||||
("x" * 33, "12345678", "32 bytes"),
|
||||
("net", "1234567", "8-63"),
|
||||
("net", "x" * 64, "8-63"),
|
||||
("net\n", "12345678", "line break"),
|
||||
])
|
||||
def test_validation_matches_firmware_limits(ssid, password, problem):
|
||||
assert any(problem in p for p in validate_wifi(ssid, password))
|
||||
with pytest.raises(ValueError):
|
||||
wifi_set_command(ssid, password)
|
||||
|
||||
|
||||
def test_password_never_logged():
|
||||
cmd = wifi_set_command("Biuro", "sekretne-haslo")
|
||||
assert "sekretne" not in loggable(cmd) and "7365" not in loggable(cmd)
|
||||
assert "Biuro" in loggable(cmd)
|
||||
|
||||
|
||||
def test_reply_parsing():
|
||||
r = parse_reply(parse_line("I (1) KOTA: cmd wifi_set ok=0 err=password_length"))
|
||||
assert r.name == "wifi_set" and not r.ok and "8-63" in r.error
|
||||
assert parse_reply(parse_line("I (1) KOTA: health up=1s")) is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- flows
|
||||
|
||||
def test_write_wifi_happy_path():
|
||||
flow, sent, events, clock = make("set", ssid="Biuro", password="haslo1234")
|
||||
flow.start()
|
||||
assert sent == ["kota ping"]
|
||||
feed(flow, PING)
|
||||
assert sent[-1] == wifi_set_command("Biuro", "haslo1234") and flow.step == Step.SEND
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_set ok=1 restart=1")
|
||||
assert flow.step == Step.RESTART
|
||||
feed(flow, "W (5101) KOTA: restart reason=usb_wifi_set")
|
||||
feed(flow, BOOT.format(mode="wifi", last="usb_wifi_set"))
|
||||
assert flow.step == Step.CONNECT
|
||||
clock.t += 6
|
||||
flow.tick()
|
||||
assert sent[-1] == "kota health"
|
||||
feed(flow, HEALTH.format(wifi="disconnected", rssi=0))
|
||||
assert flow.step == Step.CONNECT
|
||||
feed(flow, HEALTH.format(wifi="connected", rssi=-58))
|
||||
assert flow.step == Step.DONE and "Connected to 'Biuro'" in events[-1].text and "-58" in events[-1].text
|
||||
|
||||
|
||||
def test_old_firmware_without_console_times_out_on_ping():
|
||||
flow, sent, events, clock = make("read")
|
||||
flow.start()
|
||||
clock.t += PING_TIMEOUT_S + 0.1
|
||||
flow.tick()
|
||||
assert flow.step == Step.FAILED and "USB console" in events[-1].text
|
||||
|
||||
|
||||
def test_box_rejects_settings():
|
||||
flow, sent, events, _ = make("set", ssid="Biuro", password="haslo1234")
|
||||
flow.start()
|
||||
feed(flow, PING)
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_set ok=0 err=sd_not_mounted")
|
||||
assert flow.step == Step.FAILED and "SD card" in events[-1].text
|
||||
|
||||
|
||||
def test_wrong_password_does_not_connect():
|
||||
flow, _, events, clock = make("set", ssid="Biuro", password="zlehaslo1")
|
||||
flow.start()
|
||||
feed(flow, PING)
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_set ok=1 restart=1")
|
||||
feed(flow, BOOT.format(mode="wifi", last="usb_wifi_set"))
|
||||
clock.t += CONNECT_TIMEOUT_S + 1
|
||||
flow.tick()
|
||||
assert flow.step == Step.FAILED and "2.4 GHz" in events[-1].text
|
||||
|
||||
|
||||
def test_clear_returns_to_matter():
|
||||
flow, sent, events, _ = make("clear")
|
||||
flow.start()
|
||||
feed(flow, PING)
|
||||
assert sent[-1] == "kota wifi clear"
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_clear ok=1 restart=1")
|
||||
feed(flow, BOOT.format(mode="matter", last="usb_wifi_clear"))
|
||||
assert flow.step == Step.DONE and "Matter" in events[-1].text
|
||||
|
||||
|
||||
def test_read_current_settings():
|
||||
flow, sent, events, _ = make("read")
|
||||
flow.start()
|
||||
feed(flow, PING)
|
||||
assert sent[-1] == "kota wifi get"
|
||||
feed(flow, "I (5100) KOTA: cmd wifi_get ok=1 ssid=426975726f password_set=1 mode=wifi")
|
||||
assert flow.step == Step.DONE and flow.info.ssid == "Biuro" and flow.info.password_set
|
||||
assert "local Wi-Fi" in events[-1].text
|
||||
|
||||
|
||||
def test_not_connected():
|
||||
events = []
|
||||
flow = WifiFlow("read", lambda c: False, events.append)
|
||||
flow.start()
|
||||
assert flow.step == Step.FAILED and "not connected" in events[-1].text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- engine
|
||||
|
||||
def test_engine_treats_usb_wifi_restart_as_intentional_and_reads_replies():
|
||||
e = DiagnosticsEngine()
|
||||
for t in ["ESP-ROM:esp32s3-20210327", "I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341",
|
||||
BOOT.format(mode="matter", last="none"), PING,
|
||||
"I (5100) KOTA: cmd wifi_set ok=1 restart=1", "W (5101) KOTA: restart reason=usb_wifi_set",
|
||||
"ESP-ROM:esp32s3-20210327", "rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT)",
|
||||
BOOT.format(mode="wifi", last="usb_wifi_set"),
|
||||
"I (6000) KOTA: cmd wifi_get ok=1 ssid=426975726f password_set=1 mode=wifi",
|
||||
HEALTH.format(wifi="connected", rssi=-60)]:
|
||||
e.feed(parse_line(t))
|
||||
r = e.report()
|
||||
assert not any(f.rule_id.startswith("reboot.") for rc in r.roots for f in [rc.finding, *rc.consequences])
|
||||
assert e.console_api == 1
|
||||
box = dict((t, x) for t, x, _ in r.box)
|
||||
assert "network 'Biuro'" in box["Wi-Fi config"] and "Wi-Fi connected (-60 dBm)" in box["Health"]
|
||||
assert any(m == "wifi_connected" and t is not None for m, _, t in r.milestones)
|
||||
|
||||
|
||||
def test_live_v13_box_answers_usb_commands():
|
||||
# Real capture (2026-09-29): box 14:C1:9F:57:72:48 after flashing merged-binary-v13.bin App-only;
|
||||
# the Wi-Fi tab's read flow sent "kota ping" and "kota wifi get".
|
||||
from pathlib import Path
|
||||
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(Path(__file__).parent / "fixtures" / "live_boot_14C19F577248_v13_usb_console.txt"):
|
||||
e.feed(ln)
|
||||
r = e.report()
|
||||
assert e.firmware.elf_sha.startswith("3ae273ec") and e.console_api == 1
|
||||
assert dict((t, x) for t, x, _ in r.box)["Wi-Fi config"] == "no network on the SD card, mode matter"
|
||||
assert not any(f.rule_id.startswith("other.") for f in e.findings.values()) # logs intact via the driver
|
||||
@@ -43,16 +43,20 @@ APP = ["I (1299) app_init: Project name: kota-sensorboard",
|
||||
# ------------------------------------------------------------------ rules
|
||||
|
||||
def test_rules_load_and_have_no_cycles():
|
||||
rules = load_rules()
|
||||
by_id = {r.id: r for r in rules}
|
||||
for r in rules:
|
||||
seen, todo = set(), list(r.explained_by)
|
||||
from kota_tech.diag import kota
|
||||
graph = {r.id: r.explained_by for r in load_rules()}
|
||||
for st in ("ERROR", "OUT_OF_RESOURCES", "CONNECTION_ERROR", "RTC_ERROR", "SD_ERROR", "RESETING", "HTTP_SERVER"):
|
||||
for code in range(7):
|
||||
if sf := kota.status_finding(st, code):
|
||||
graph[sf[0]] = sf[5]
|
||||
for start, explained_by in graph.items():
|
||||
seen, todo = set(), list(explained_by)
|
||||
while todo:
|
||||
rid = todo.pop()
|
||||
assert rid != r.id, f"explained_by cycle through {r.id}"
|
||||
assert rid != start, f"explained_by cycle through {start}"
|
||||
if rid not in seen:
|
||||
seen.add(rid)
|
||||
todo += by_id[rid].explained_by
|
||||
todo += graph.get(rid, ())
|
||||
|
||||
|
||||
# --------------------------------------------------------- real captures
|
||||
@@ -233,3 +237,23 @@ def test_session_log_format(tmp_path):
|
||||
for ln in lines:
|
||||
e.feed(ln)
|
||||
assert e.boot.tool_reset
|
||||
|
||||
|
||||
def test_live_brownout_loop_is_a_power_problem():
|
||||
# Real capture (2026-09-28): "E BOD: Brownout detector was triggered" has no
|
||||
# timestamp and the ROM reports RTC_SW_SYS_RST, not a brown-out reset.
|
||||
r = replay("live_brownout_loop_A4CB8FC946F8_fw1.0.txt")
|
||||
top = r.roots[0]
|
||||
assert top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5
|
||||
assert "reboot.loop" in {c.rule_id for c in top.consequences}
|
||||
assert r.verdict.startswith("Problem: Power supply")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line", [
|
||||
"E (2893) wifi:sta is connecting, return error",
|
||||
"E (2892) chip[DL]: esp_wifi_connect() failed: ESP_ERR_WIFI_CONN",
|
||||
"E (5120) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F",
|
||||
])
|
||||
def test_harmless_wifi_start_errors_are_not_reported(line):
|
||||
_, r = run(BOOT + APP + [line])
|
||||
assert r.roots == [] and "Unrecognised" not in r.to_text()
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Flashing: image validation on the real official image, plan building,
|
||||
and the runner against a fake esptool (no hardware)."""
|
||||
"""Flashing: image validation on the real image in firmware/, the folder
|
||||
catalogue (compile-date order), plan building, and the runner against a fake
|
||||
esptool (no hardware)."""
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
@@ -8,31 +9,39 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.flash.image import ImageError, inspect_image, official_images
|
||||
from kota_tech.flash.image import ImageError, app_image_length, folder_images, inspect_image
|
||||
from kota_tech.flash.plan import Mode, build_plan, check_partition_table
|
||||
from kota_tech.flash.runner import FlashRunner
|
||||
|
||||
FW = Path(__file__).resolve().parents[1] / "firmware" / "new_system.bin"
|
||||
FW_DIR = Path(__file__).resolve().parents[1] / "firmware"
|
||||
FW = sorted(FW_DIR.glob("*.bin"))[0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def image():
|
||||
imgs = official_images()
|
||||
assert len(imgs) == 1
|
||||
imgs, problems = folder_images()
|
||||
assert imgs and problems == []
|
||||
return imgs[0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ image
|
||||
|
||||
def test_official_image_is_the_deployed_v1_build(image):
|
||||
def test_folder_image_is_a_kota_build(image):
|
||||
assert image.app.project == "kota-sensorboard"
|
||||
assert image.app.elf_sha.startswith("45fa9d5b9") # same as the live board
|
||||
assert image.kind == "merged"
|
||||
assert image.app.built_at is not None # compile date is readable
|
||||
assert image.flash_size == "8MB"
|
||||
assert image.partition("ota_0").offset == 0x40000
|
||||
assert image.partition("otadata").offset == 0x31000
|
||||
assert image.partition("nvs").offset == 0x10000
|
||||
|
||||
|
||||
def test_label_starts_with_compile_date(image):
|
||||
label = image.label()
|
||||
assert label.startswith(f"Compiled {image.app.built_at:%Y-%m-%d %H:%M}")
|
||||
assert image.app.version in label and image.name in label
|
||||
|
||||
|
||||
def test_app_bytes_are_a_complete_app_image(image):
|
||||
app = image.app_bytes()
|
||||
assert app[0] == 0xE9
|
||||
@@ -40,17 +49,6 @@ def test_app_bytes_are_a_complete_app_image(image):
|
||||
assert len(image.blank_otadata()) == 0x2000 and set(image.blank_otadata()) == {0xFF}
|
||||
|
||||
|
||||
def test_modified_image_is_refused(tmp_path):
|
||||
data = bytearray(FW.read_bytes())
|
||||
data[-1] ^= 0xFF
|
||||
bad = tmp_path / "new_system.bin"
|
||||
bad.write_bytes(data)
|
||||
manifest = tmp_path / "firmware.toml"
|
||||
manifest.write_text(f'[[image]]\nfile = "new_system.bin"\nsha256 = "{hashlib.sha256(FW.read_bytes()).hexdigest()}"\n')
|
||||
with pytest.raises(ImageError, match="SHA-256"):
|
||||
official_images(manifest)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patch,msg", [
|
||||
((0, 0x00), "not an ESP32 firmware"),
|
||||
((12, 0x05), "not ESP32-S3"),
|
||||
@@ -64,6 +62,93 @@ def test_wrong_images_are_rejected(tmp_path, patch, msg):
|
||||
inspect_image(p)
|
||||
|
||||
|
||||
def test_integrity_check_on_folder_image(image):
|
||||
d = image.data
|
||||
assert app_image_length(d, 0) > 0 # bootloader
|
||||
assert app_image_length(d, 0x40000) == len(d) - 0x40000 # app ends exactly at file end
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage,msg", [
|
||||
(lambda b: b[:len(b) // 2], "truncated"),
|
||||
(lambda b: b[:0x40000 + 5000] + bytes([b[0x40000 + 5000] ^ 1]) + b[0x40000 + 5001:], "corrupted"),
|
||||
])
|
||||
def test_corrupted_or_truncated_images_refused(tmp_path, damage, msg):
|
||||
p = tmp_path / "broken.bin"
|
||||
p.write_bytes(damage(FW.read_bytes()))
|
||||
with pytest.raises(ImageError, match=msg):
|
||||
inspect_image(p)
|
||||
|
||||
|
||||
# ------------------------------------------------------- firmware folder
|
||||
|
||||
def _with_compile_date(data: bytes, date: str, time: str, off: int = 0x40000) -> bytes:
|
||||
"""Copy of a merged image whose app claims another compile date, with the
|
||||
segment checksum and appended SHA-256 recomputed (a valid, different build)."""
|
||||
d = bytearray(data)
|
||||
desc = off + 0x20
|
||||
old = bytes(d[desc + 80:desc + 112])
|
||||
new = time.encode().ljust(16, b"\0") + date.encode().ljust(16, b"\0")
|
||||
d[desc + 80:desc + 112] = new
|
||||
end = off + app_image_length(data, off) - 32 # SHA-256 follows the checksum byte
|
||||
xor = 0
|
||||
for a, b in zip(old, new):
|
||||
xor ^= a ^ b
|
||||
d[end - 1] ^= xor
|
||||
d[end:end + 32] = hashlib.sha256(bytes(d[off:end])).digest()
|
||||
return bytes(d)
|
||||
|
||||
|
||||
def test_folder_lists_builds_newest_compile_date_first(tmp_path):
|
||||
data = FW.read_bytes()
|
||||
(tmp_path / "a_old.bin").write_bytes(_with_compile_date(data, "Jan 5 2026", "08:00:00"))
|
||||
(tmp_path / "z_new.bin").write_bytes(_with_compile_date(data, "Dec 24 2026", "18:30:00"))
|
||||
(tmp_path / "m_mid.bin").write_bytes(_with_compile_date(data, "Jun 15 2026", "12:00:00"))
|
||||
imgs, problems = folder_images(tmp_path)
|
||||
assert problems == []
|
||||
assert [i.name for i in imgs] == ["z_new.bin", "m_mid.bin", "a_old.bin"]
|
||||
assert imgs[0].label().startswith("Compiled 2026-12-24 18:30")
|
||||
assert imgs[2].label().startswith("Compiled 2026-01-05 08:00")
|
||||
|
||||
|
||||
def test_folder_reports_bad_files_and_duplicates(tmp_path):
|
||||
data = FW.read_bytes()
|
||||
(tmp_path / "good.bin").write_bytes(data)
|
||||
(tmp_path / "copy.bin").write_bytes(data)
|
||||
(tmp_path / "notes.bin").write_bytes(b"hello firmware team")
|
||||
(tmp_path / "readme.txt").write_text("ignored: not a .bin")
|
||||
imgs, problems = folder_images(tmp_path)
|
||||
assert len(imgs) == 1
|
||||
assert any("notes.bin" in p and "not an ESP32 firmware" in p for p in problems)
|
||||
assert any("same file as" in p for p in problems)
|
||||
assert not any("readme" in p for p in problems)
|
||||
|
||||
|
||||
def test_app_only_file_uses_the_merged_layout(tmp_path, image):
|
||||
(tmp_path / "merged.bin").write_bytes(image.data)
|
||||
(tmp_path / "kota-sensorboard.bin").write_bytes(
|
||||
_with_compile_date(image.data, "Dec 1 2026", "10:00:00")[0x40000:])
|
||||
imgs, problems = folder_images(tmp_path)
|
||||
assert problems == []
|
||||
app = next(i for i in imgs if i.kind == "app")
|
||||
assert imgs[0] is app # newer compile date first
|
||||
assert app.partition("ota_0").offset == 0x40000 and "app only" in app.label()
|
||||
assert [s.kind for s in build_plan(app, Mode.APP_ONLY, "COM5", tmp_path / "w").steps] == ["read_pt", "write"]
|
||||
with pytest.raises(ValueError, match="app only"):
|
||||
build_plan(app, Mode.FULL, "COM5", tmp_path / "w")
|
||||
|
||||
|
||||
def test_app_only_file_without_merged_image_is_not_offered(tmp_path, image):
|
||||
(tmp_path / "kota-sensorboard.bin").write_bytes(image.app_bytes())
|
||||
imgs, problems = folder_images(tmp_path)
|
||||
assert imgs == [] and "no complete (merged) image" in problems[0]
|
||||
|
||||
|
||||
def test_empty_or_missing_folder(tmp_path):
|
||||
assert folder_images(tmp_path) == ([], [])
|
||||
imgs, problems = folder_images(tmp_path / "nope")
|
||||
assert imgs == [] and "not found" in problems[0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- plan
|
||||
|
||||
def test_app_only_plan_keeps_nvs(image, tmp_path):
|
||||
@@ -160,115 +245,3 @@ def test_runner_reports_connect_failure_with_hint(image, tmp_path):
|
||||
res, *_ = _run(plan, cmd)
|
||||
assert not res.ok and res.failed_step == 0
|
||||
assert "BOOT" in res.hint
|
||||
|
||||
|
||||
# ------------------------------------------------- developer builds (v0.5)
|
||||
|
||||
from kota_tech.flash.image import FirmwareLibrary, app_image_length # noqa: E402
|
||||
|
||||
|
||||
def _app_only_file(image, tmp_path, name="kota-sensorboard.bin"):
|
||||
p = tmp_path / name
|
||||
p.write_bytes(image.app_bytes()) # what a developer build/ folder contains
|
||||
return p
|
||||
|
||||
|
||||
def test_integrity_check_on_factory_image(image):
|
||||
d = image.data
|
||||
assert app_image_length(d, 0) > 0 # bootloader
|
||||
assert app_image_length(d, 0x40000) == len(d) - 0x40000 # app ends exactly at file end
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage,msg", [
|
||||
(lambda b: b[:len(b) // 2], "truncated"),
|
||||
(lambda b: b[:0x40000 + 5000] + bytes([b[0x40000 + 5000] ^ 1]) + b[0x40000 + 5001:], "corrupted"),
|
||||
])
|
||||
def test_corrupted_or_truncated_images_refused(tmp_path, damage, msg):
|
||||
p = tmp_path / "broken.bin"
|
||||
p.write_bytes(damage(FW.read_bytes()))
|
||||
with pytest.raises(ImageError, match=msg):
|
||||
inspect_image(p, origin="developer")
|
||||
|
||||
|
||||
def test_import_app_only_developer_build(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
src = _app_only_file(image, tmp_path)
|
||||
img = lib.import_file(src, reference=image)
|
||||
assert img.is_developer and img.kind == "app"
|
||||
assert img.partition("ota_0").offset == 0x40000 # layout from the factory image
|
||||
assert img.app_bytes() == image.app_bytes()
|
||||
listed, problems = lib.images(image)
|
||||
assert [i.sha256 for i in listed] == [img.sha256] and problems == []
|
||||
assert lib.import_file(src, reference=image).sha256 == img.sha256 # re-import: no duplicate
|
||||
assert len(lib.images(image)[0]) == 1
|
||||
|
||||
|
||||
def test_app_only_build_cannot_be_flashed_full(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
plan = build_plan(img, Mode.APP_ONLY, "COM5", tmp_path / "w")
|
||||
assert [s.kind for s in plan.steps] == ["read_pt", "write"]
|
||||
with pytest.raises(ValueError, match="app only"):
|
||||
build_plan(img, Mode.FULL, "COM5", tmp_path / "w")
|
||||
|
||||
|
||||
def test_import_merged_developer_build(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
src = tmp_path / "dev_full.bin"
|
||||
src.write_bytes(image.data)
|
||||
img = lib.import_file(src, reference=image)
|
||||
assert img.kind == "merged" and img.is_developer
|
||||
assert [s.kind for s in build_plan(img, Mode.FULL, "COM5", tmp_path / "w").steps] == ["write"]
|
||||
|
||||
|
||||
def test_library_detects_file_changed_after_import(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
stored = tmp_path / "lib" / img.path.name
|
||||
data = bytearray(stored.read_bytes())
|
||||
data[-40] ^= 1
|
||||
stored.write_bytes(data)
|
||||
listed, problems = lib.images(image)
|
||||
assert listed == [] and problems
|
||||
|
||||
|
||||
def test_library_remove(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
|
||||
lib.remove(img.sha256)
|
||||
assert lib.images(image) == ([], []) and not img.path.exists()
|
||||
|
||||
|
||||
def test_non_firmware_file_refused(tmp_path, image):
|
||||
p = tmp_path / "notes.bin"
|
||||
p.write_bytes(b"hello firmware team")
|
||||
with pytest.raises(ImageError, match="not an ESP32 firmware"):
|
||||
FirmwareLibrary(tmp_path / "lib").import_file(p, reference=image)
|
||||
|
||||
|
||||
# ------------------------------------------- bundled developer build (v0.6)
|
||||
|
||||
from kota_tech.flash.image import bundled_images # noqa: E402
|
||||
|
||||
|
||||
def test_bundled_developer_build_is_labelled_and_not_default():
|
||||
imgs = bundled_images()
|
||||
assert [i.origin for i in imgs] == ["factory", "developer"] # factory first
|
||||
dev = imgs[1]
|
||||
assert dev.built_by == "Krzysztof Kallas" and "built by Krzysztof Kallas" in dev.label()
|
||||
assert dev.kind == "merged" and dev.app.elf_sha.startswith("c94ba0e9b")
|
||||
assert dev.partition_table == imgs[0].partition_table # App-only safe on v1.0 boxes
|
||||
assert [i.origin for i in factory_images_only()] == ["factory"]
|
||||
|
||||
|
||||
def factory_images_only():
|
||||
from kota_tech.flash.image import factory_images
|
||||
return factory_images()
|
||||
|
||||
|
||||
def test_import_records_who_built_it(image, tmp_path):
|
||||
lib = FirmwareLibrary(tmp_path / "lib")
|
||||
img = lib.import_file(_app_only_file(image, tmp_path), reference=image, built_by=" Anna Nowak ")
|
||||
assert img.built_by == "Anna Nowak" and "built by Anna Nowak" in img.label()
|
||||
again, _ = lib.images(image)
|
||||
assert again[0].built_by == "Anna Nowak"
|
||||
@@ -242,3 +242,107 @@ def test_skip_wait_and_report_contains_technician_checks(rig):
|
||||
assert runner.end.id == "update_slow"
|
||||
text = engine.report().to_text()
|
||||
assert "Technician checks:" in text and "Update is taking very long" in text
|
||||
|
||||
|
||||
# ------------------------------------------------------------ InfluxDB check
|
||||
RTC_STOPPED = [*BOOT, "I (2945) KOTA: rtc running=0 time=2000-01-01T00:00:00"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def influx_rig(rig):
|
||||
engine, runner, clock, actions, board, log = rig
|
||||
return engine, runner, clock, actions, board
|
||||
|
||||
|
||||
def test_influx_ok_ends_healthy(influx_rig):
|
||||
engine, runner, clock, actions, board = influx_rig
|
||||
runner.start("influx")
|
||||
assert actions == ["influx"] and runner.view().waiting
|
||||
runner.influx_result("ok", "Sensor data arrives in InfluxDB", ["Device status: 1 min ago"])
|
||||
assert runner.end.id == "data_ok"
|
||||
assert any("InfluxDB: Sensor data arrives" in t for t in engine.technician)
|
||||
|
||||
|
||||
def test_influx_polls_until_sensors_arrive(influx_rig):
|
||||
engine, runner, clock, actions, board = influx_rig
|
||||
runner.start("influx")
|
||||
runner.influx_result("sensors_missing", "Status arrives, sensor data does not: SEN66")
|
||||
assert runner.end is None and "Checking again" in runner.view().note
|
||||
clock.t += 10
|
||||
runner.tick()
|
||||
assert actions == ["influx"] # not yet
|
||||
clock.t += 25
|
||||
runner.tick()
|
||||
assert actions == ["influx", "influx"] # polled again after poll_s
|
||||
runner.tick()
|
||||
assert actions == ["influx", "influx"] # no second query while one is pending
|
||||
runner.influx_result("ok", "Sensor data arrives in InfluxDB")
|
||||
assert runner.end.id == "data_ok"
|
||||
|
||||
|
||||
def test_influx_repeated_answer_is_noted_once(influx_rig):
|
||||
engine, runner, clock, actions, board = influx_rig
|
||||
runner.start("influx")
|
||||
for _ in range(3):
|
||||
runner.influx_result("silent", "No recent data in InfluxDB")
|
||||
clock.t += 31
|
||||
runner.tick()
|
||||
assert sum("InfluxDB:" in t for t in engine.technician) == 1
|
||||
|
||||
|
||||
def test_influx_sensors_missing_with_stopped_clock(influx_rig):
|
||||
engine, runner, clock, actions, board = influx_rig
|
||||
board(*RTC_STOPPED)
|
||||
runner.start("influx")
|
||||
runner.influx_result("sensors_missing", "Status arrives, sensor data does not")
|
||||
clock.t += 300 # past timeout_s
|
||||
runner.tick()
|
||||
assert runner.end.id == "sensors_clock"
|
||||
f = engine.findings["tech.influx.sensors_clock"]
|
||||
assert f.severity == Severity.ERROR and f.active
|
||||
|
||||
|
||||
def test_influx_sensors_missing_without_log_cause(influx_rig):
|
||||
engine, runner, clock, actions, board = influx_rig
|
||||
runner.start("influx")
|
||||
clock.t += 250
|
||||
runner.influx_result("sensors_missing", "Status arrives, sensor data does not")
|
||||
assert runner.end.id == "sensors_missing"
|
||||
|
||||
|
||||
def test_influx_unavailable_and_stale_results(rig):
|
||||
engine, runner, clock, actions, board, log = rig
|
||||
runner._on_action = lambda a: False # no token / host cannot query
|
||||
runner.start("influx")
|
||||
assert runner.end.id == "unavailable"
|
||||
runner.influx_result("ok", "late answer") # ignored: step already left
|
||||
assert runner.end.id == "unavailable"
|
||||
|
||||
|
||||
def test_influx_skip_uses_last_result(influx_rig):
|
||||
engine, runner, clock, actions, board = influx_rig
|
||||
runner.start("influx")
|
||||
runner.influx_result("silent", "No recent data in InfluxDB")
|
||||
runner.proceed()
|
||||
assert runner.end.id == "silent"
|
||||
|
||||
|
||||
def test_quick_check_green_ends_with_influx_check(influx_rig):
|
||||
engine, runner, clock, actions, board = influx_rig
|
||||
board(*BOOT)
|
||||
runner.start("quick")
|
||||
runner.answer("GREEN")
|
||||
runner.answer("0") # heartbeat blinking
|
||||
assert runner.guide.id == "influx" and actions == ["influx"]
|
||||
|
||||
|
||||
def test_influx_rejected_upload_is_named(influx_rig):
|
||||
engine, runner, clock, actions, board = influx_rig
|
||||
board(*BOOT, "microphone,device=A4CB8FC9473C dbfs=-inf,db=-inf 1790805114000000000",
|
||||
"io,device=A4CB8FC9473C dbfs=-inf,db=-inf 1790805215000000000") # USB dropped characters
|
||||
assert sum(k.startswith("influx.invalid_value") for k in engine.findings) == 1
|
||||
runner.start("influx")
|
||||
runner.influx_result("silent", "No new data in InfluxDB since the test started")
|
||||
clock.t += 300
|
||||
runner.tick()
|
||||
assert runner.end.id == "invalid_value"
|
||||
@@ -0,0 +1,154 @@
|
||||
"""InfluxDB check: query text, CSV parsing, classification, failures (no network)."""
|
||||
|
||||
import datetime as dt
|
||||
import urllib.error
|
||||
|
||||
from kota_tech.diag.guides import INFLUX_OUTCOMES
|
||||
from kota_tech.service import influx
|
||||
|
||||
NOW = dt.datetime(2026, 9, 30, 12, 0, tzinfo=dt.timezone.utc)
|
||||
UTC = dt.timezone.utc
|
||||
|
||||
# Real answer shape of /api/v2/query (Accept: application/csv).
|
||||
CSV = (
|
||||
",result,table,_measurement,_time\r\n"
|
||||
",_result,0,as7341_adc,2026-09-30T11:55:01Z\r\n"
|
||||
",_result,0,bme688,2026-09-30T11:55:02.123456789Z\r\n"
|
||||
",_result,0,device_status,2026-09-30T11:58:00Z\r\n"
|
||||
",_result,0,microphone,2026-09-30T11:58:10Z\r\n"
|
||||
",_result,0,sen66,2026-09-30T11:56:00Z\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
|
||||
|
||||
def _t(minutes_ago):
|
||||
return NOW - dt.timedelta(minutes=minutes_ago)
|
||||
|
||||
|
||||
def test_outcomes_match_guide_runner():
|
||||
assert influx.OUTCOMES == INFLUX_OUTCOMES
|
||||
|
||||
|
||||
def test_query_is_for_one_box_and_keeps_only_time():
|
||||
q = influx.build_query("sensorbox", "A4CB8FC9473C")
|
||||
assert 'from(bucket: "sensorbox")' in q and 'r.device == "A4CB8FC9473C"' in q
|
||||
# _value must be dropped before grouping (string and float fields clash -> HTTP 400)
|
||||
assert q.index("keep(") < q.index("group(columns")
|
||||
|
||||
|
||||
def test_parse_csv_newest_per_measurement():
|
||||
latest = influx.parse_csv(CSV)
|
||||
assert latest["bme688"] == dt.datetime(2026, 9, 30, 11, 55, 2, 123456, tzinfo=UTC)
|
||||
assert set(latest) == {"as7341_adc", "bme688", "device_status", "microphone", "sen66"}
|
||||
assert influx.parse_csv("") == {}
|
||||
|
||||
|
||||
def test_all_sensors_fresh_is_ok():
|
||||
c = influx.classify(influx.parse_csv(CSV), NOW)
|
||||
assert c.outcome == influx.OK and not c.missing
|
||||
|
||||
|
||||
def test_status_without_sensors_is_sensors_missing():
|
||||
# box A4CB8FC9473C: RTC stopped, only status and microphone ever arrived
|
||||
c = influx.classify({"device_status": _t(1), "microphone": _t(1)}, NOW)
|
||||
assert c.outcome == influx.SENSORS_MISSING
|
||||
assert c.missing == list(influx.SENSOR_GROUPS)
|
||||
assert any("never started" in d for d in c.details)
|
||||
|
||||
|
||||
def test_one_sensor_missing():
|
||||
latest = {"device_status": _t(1), "as7341_adc": _t(2), "bme688": _t(2), "sen66": _t(300)}
|
||||
c = influx.classify(latest, NOW)
|
||||
assert c.outcome == influx.SENSORS_MISSING and c.missing == ["SEN66 (air quality)"]
|
||||
|
||||
|
||||
def test_offline_box_whose_sensors_stopped_before_status():
|
||||
# box A4CB8FC946F8: status 29 h ago, sensors 21 days ago
|
||||
latest = {"device_status": _t(29 * 60), "microphone": _t(29 * 60),
|
||||
"as7341_adc": _t(21 * 1440), "bme688": _t(21 * 1440), "sen66": _t(21 * 1440)}
|
||||
c = influx.classify(latest, NOW)
|
||||
assert c.outcome == influx.SENSORS_MISSING and "Nothing new" in c.summary
|
||||
assert any("stopped" in d for d in c.details)
|
||||
assert any("21 days ago" in d for d in c.details)
|
||||
|
||||
|
||||
def test_offline_box_with_complete_history_is_silent():
|
||||
# box A4CB8FC739EC: everything stopped together 11 days ago
|
||||
latest = {"device_status": _t(11 * 1440), "microphone": _t(11 * 1440),
|
||||
"as7341_adc": _t(11 * 1440 + 3), "bme688": _t(11 * 1440 + 3), "sen66": _t(11 * 1440 + 2)}
|
||||
c = influx.classify(latest, NOW)
|
||||
assert c.outcome == influx.SILENT
|
||||
assert not any("stopped" in d for d in c.details)
|
||||
|
||||
|
||||
def test_no_data_is_never():
|
||||
assert influx.classify({}, NOW).outcome == influx.NEVER
|
||||
|
||||
|
||||
def test_future_timestamps_are_flagged():
|
||||
latest = {"device_status": _t(1), "microphone": _t(1), "as7341_adc": _t(-120), "bme688": _t(-120),
|
||||
"sen66": _t(-120)}
|
||||
c = influx.classify(latest, NOW)
|
||||
assert any("future" in d for d in c.details)
|
||||
|
||||
|
||||
def test_check_device_without_token_or_mac():
|
||||
assert influx.check_device(None, "A4CB8FC9473C").outcome == influx.UNAVAILABLE
|
||||
cfg = influx.InfluxConfig("t")
|
||||
assert influx.check_device(cfg, "").outcome == influx.UNAVAILABLE
|
||||
|
||||
|
||||
def test_check_device_errors_never_raise(monkeypatch):
|
||||
cfg = influx.InfluxConfig("secret-token")
|
||||
|
||||
def rejected(*_a, **_k):
|
||||
raise urllib.error.HTTPError("u", 401, "Unauthorized", {}, None)
|
||||
|
||||
monkeypatch.setattr(influx.urllib.request, "urlopen", rejected)
|
||||
c = influx.check_device(cfg, "a4:cb:8f:c9:47:3c")
|
||||
assert c.outcome == influx.UNAVAILABLE and "rejected" in c.summary
|
||||
assert "secret-token" not in c.summary
|
||||
|
||||
def offline(*_a, **_k):
|
||||
raise urllib.error.URLError("nodename nor servname provided")
|
||||
|
||||
monkeypatch.setattr(influx.urllib.request, "urlopen", offline)
|
||||
assert "not reachable" in influx.check_device(cfg, "A4CB8FC9473C").summary
|
||||
|
||||
|
||||
def test_check_device_sends_token_header_and_normalized_mac(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
class Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return CSV.encode()
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
seen["req"] = req
|
||||
return Resp()
|
||||
|
||||
monkeypatch.setattr(influx.urllib.request, "urlopen", fake_urlopen)
|
||||
c = influx.check_device(influx.InfluxConfig("tok"), "a4:cb:8f:c9:47:3c", now=NOW)
|
||||
req = seen["req"]
|
||||
assert c.outcome == influx.OK
|
||||
assert req.get_header("Authorization") == "Token tok"
|
||||
assert "tok" not in req.full_url and req.full_url.endswith("/api/v2/query?org=kota")
|
||||
assert b'"A4CB8FC9473C"' in req.data
|
||||
|
||||
|
||||
def test_since_ignores_data_from_before_the_test():
|
||||
# healthy a few minutes ago, but nothing since the box was restarted by the test
|
||||
latest = {"device_status": _t(5), "microphone": _t(5), "as7341_adc": _t(5), "bme688": _t(5), "sen66": _t(5)}
|
||||
assert influx.classify(latest, NOW).outcome == influx.OK
|
||||
c = influx.classify(latest, NOW, since=_t(2))
|
||||
assert c.outcome == influx.SILENT and "since the test started" in c.summary
|
||||
latest.update(device_status=_t(0), microphone=_t(0))
|
||||
assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.SENSORS_MISSING
|
||||
latest.update(as7341_adc=_t(0), bme688=_t(0), sen66=_t(2.5)) # within the clock slack
|
||||
assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.OK
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Firmware 1.0.16+ structured "KOTA:" lines and error IDs.
|
||||
|
||||
The lines below are built from the exact format strings in the firmware
|
||||
source (boot_info.cpp, status.cpp, i2c.cpp, ds1307.cpp, sd_card.cpp,
|
||||
main.cpp, microphone.cpp, health.cpp); no hardware capture exists yet.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
from kota_tech.core.logline import LineKind, parse_line
|
||||
from kota_tech.diag.engine import DiagnosticsEngine, Severity
|
||||
from kota_tech.diag.firmware import same_build
|
||||
from kota_tech.matter.payload import payload_from_log
|
||||
from kota_tech.service.report import CONNECTIVITY, HARDWARE, service_status
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
BANNER = ["ESP-ROM:esp32s3-20210327", "rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)"]
|
||||
I2C_OK = "I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688"
|
||||
SD_OK = ("I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB "
|
||||
"env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=0")
|
||||
BOOT = ("I (2310) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET "
|
||||
"mode=matter commissioned=1 last_restart={last}")
|
||||
|
||||
|
||||
def run(lines):
|
||||
e = DiagnosticsEngine()
|
||||
for i, text in enumerate(lines):
|
||||
e.feed(parse_line(text, host_time=i * 0.01))
|
||||
return e, e.report()
|
||||
|
||||
|
||||
def roots(report):
|
||||
return {rc.finding.rule_id: rc for rc in report.roots}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- parsing
|
||||
|
||||
def test_kota_lines_are_parsed_into_fields():
|
||||
ln = parse_line(BOOT.format(last="status_watchdog"))
|
||||
assert ln.kind == LineKind.KOTA and ln.record == "boot" and ln.level == "I"
|
||||
assert ln.fields["fw"] == "1.0.16" and ln.fields["last_restart"] == "status_watchdog"
|
||||
st = parse_line("I (5) KOTA: status OK -> CONNECTION_ERROR code=1 reason=influx_rejected")
|
||||
assert st.words == ("OK", "->", "CONNECTION_ERROR") and st.fields == {"code": "1", "reason": "influx_rejected"}
|
||||
mic = parse_line("I (6) KOTA: mic enabled=0 (GPIO3=1, expected 0)")
|
||||
assert mic.fields["enabled"] == "0" and mic.fields["GPIO3"] == "1"
|
||||
i2c = parse_line("I (7) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307(missing) 0x50=?")
|
||||
assert i2c.fields["0x68"] == "DS1307(missing)" and i2c.fields["0x50"] == "?"
|
||||
verify = parse_line("I (8) KOTA: rtc verify running=1 delta=2s ok=1")
|
||||
assert verify.record == "rtc" and "verify" in verify.words and verify.fields["ok"] == "1"
|
||||
|
||||
|
||||
def test_error_id_is_split_off_but_kept_in_text():
|
||||
ln = parse_line("E (1200) SD_CARD: [E-SD-03] Failed to initialize the card (ESP_ERR_TIMEOUT).")
|
||||
assert ln.kind == LineKind.LOG and ln.error_id == "E-SD-03"
|
||||
assert ln.message.startswith("Failed to initialize") and "[E-SD-03]" in ln.text
|
||||
assert ln.match_text == "E (1200) SD_CARD: Failed to initialize the card (ESP_ERR_TIMEOUT)."
|
||||
|
||||
|
||||
def test_sd_line_with_secret_key_names_is_not_redacted():
|
||||
ln = parse_line("I (9) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB "
|
||||
"env_keys=INFLUX_URL,INFLUX_TOKEN,SSID,PASSWORD buffer_lines=0")
|
||||
assert ln.fields["env_keys"] == "INFLUX_URL,INFLUX_TOKEN,SSID,PASSWORD"
|
||||
|
||||
|
||||
# -------------------------------------------------------------- engine
|
||||
|
||||
def test_healthy_box_from_kota_lines():
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(FIX / "synthetic_fw1.0.16_healthy.txt"):
|
||||
e.feed(ln)
|
||||
r = e.report()
|
||||
assert r.verdict.startswith("Healthy")
|
||||
assert (r.led, r.led_pattern, r.led_exact, r.led_meaning) == ("GREEN", "solid", True, "OK")
|
||||
assert "v1.0.16" in r.firmware.describe() and "4f10989e" in r.firmware.describe()
|
||||
assert e.mac == "A4:CB:8F:C9:46:F8"
|
||||
reached = {m for m, _, t in r.milestones if t is not None}
|
||||
assert {"i2c_ok", "sd_mounted", "env_loaded", "matter_commissioned", "wifi_connected",
|
||||
"time_synced", "clock_verified", "data_uploaded", "ota_registered"} <= reached
|
||||
topics = {t: st for t, _, st in r.box}
|
||||
assert topics["I2C"] == topics["SD card"] == topics["Health"] == "ok"
|
||||
# The purple state before the tool's reset is shown, but only as information now.
|
||||
prev = [f for f in r.earlier + [rc.finding for rc in r.roots] if f.rule_id == "boot.previous_status"]
|
||||
assert not prev or prev[0].severity == Severity.INFO
|
||||
assert "E-MAIN-04" not in r.to_text() # MAC line is a benign ESP_LOGE
|
||||
|
||||
|
||||
def test_i2c_bus_stuck_is_root_of_blue_led():
|
||||
_, r = run(BANNER + [
|
||||
"I (1480) KOTA: led color=blue pattern=pulses n=2",
|
||||
"I (1790) KOTA: i2c SDA=0 SCL=1 bus=stuck",
|
||||
"I (1791) KOTA: status OK -> RTC_ERROR code=2 reason=i2c_bus_stuck",
|
||||
])
|
||||
rs = roots(r)
|
||||
assert "kota.i2c_stuck" in rs and rs["kota.i2c_stuck"].finding.severity == Severity.CRITICAL
|
||||
assert "status.rtc.2" in {c.rule_id for c in rs["kota.i2c_stuck"].consequences}
|
||||
assert (r.led, r.led_pattern) == ("BLUE", "2 pulses") and "I2C bus stuck" in r.led_meaning
|
||||
assert service_status(r) == HARDWARE
|
||||
|
||||
|
||||
def test_missing_sensor_named_by_i2c_scan_and_red_pulses():
|
||||
_, r = run(BANNER + [
|
||||
"I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66(missing) 0x76=BME688",
|
||||
SD_OK,
|
||||
"E (2100) MAIN: [E-MAIN-16] Failed to initialize SEN66: ESP_ERR_TIMEOUT",
|
||||
"I (2101) KOTA: status OK -> ERROR code=2 reason=sen66_init_failed",
|
||||
"I (2102) KOTA: led color=red pattern=pulses n=2",
|
||||
])
|
||||
rs = roots(r)
|
||||
root = rs["kota.sensor_missing"]
|
||||
assert root.finding.title == "SEN66 does not answer on the I2C bus"
|
||||
grouped = {c.rule_id for c in root.consequences}
|
||||
assert {"status.error.2", "sensor.init_failed"} <= grouped
|
||||
init = next(c for c in root.consequences if c.rule_id == "sensor.init_failed")
|
||||
assert "E-MAIN-16" in init.error_ids and "SEN66" in init.error_ids["E-MAIN-16"]
|
||||
assert r.led_meaning == "SEN66 failed"
|
||||
assert "missing: SEN66" in dict((t, x) for t, x, _ in r.box)["I2C"]
|
||||
|
||||
|
||||
def test_server_rejected_box_is_purple_blinking():
|
||||
_, r = run(BANNER + [I2C_OK, SD_OK, BOOT.format(last="none"),
|
||||
"I (70000) KOTA: status OK -> CONNECTION_ERROR code=1 reason=influx_rejected",
|
||||
"I (70001) KOTA: led color=purple pattern=blink period=1000ms"])
|
||||
assert r.roots[0].finding.rule_id == "status.conn.1"
|
||||
assert r.roots[0].finding.severity == Severity.CRITICAL
|
||||
assert (r.led, r.led_pattern) == ("PURPLE", "blinking (1000 ms)")
|
||||
assert "server rejected" in r.led_meaning
|
||||
assert "influx_rejected" in r.led_reason
|
||||
assert service_status(r) == CONNECTIVITY
|
||||
|
||||
|
||||
def test_status_back_to_ok_resolves_the_status_finding():
|
||||
_, r = run(BANNER + [I2C_OK, "I (5000) KOTA: status OK -> CONNECTION_ERROR reason=influx_failed",
|
||||
"I (9000) KOTA: status CONNECTION_ERROR -> OK reason=influx_ok"])
|
||||
assert "status.conn" not in roots(r)
|
||||
assert any(f.rule_id == "status.conn" and f.resolved_by == "status -> OK" for f in r.resolved)
|
||||
|
||||
|
||||
def test_restart_reason_from_the_next_boot_line():
|
||||
_, r = run(BANNER + [I2C_OK, BOOT.format(last="none"),
|
||||
"I (5000) KOTA: status OK -> ERROR code=3 reason=as7341_failed",
|
||||
"W (155000) KOTA: restart reason=status_watchdog",
|
||||
"I (155001) KOTA: led restart_flash color=red",
|
||||
"ESP-ROM:esp32s3-20210327", "rst:0xc (RTC_SW_CPU_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)",
|
||||
"I (1480) KOTA: led previous_boot status=ERROR code=3",
|
||||
I2C_OK, BOOT.format(last="status_watchdog")])
|
||||
ids = {f.rule_id: f for rc in r.roots for f in [rc.finding, *rc.consequences]}
|
||||
f = ids["reboot.status_watchdog"]
|
||||
assert "150 s" in f.title and "state ERROR code=3" in f.meaning
|
||||
|
||||
|
||||
def test_brownout_restart_recorded_by_firmware():
|
||||
_, r = run(BANNER + [I2C_OK, BOOT.format(last="none"), "I (90000) KOTA: health up=90s status=OK "
|
||||
"wifi=connected rssi=-60 time_valid=1 sd=ok buffer=0 heap=150000 psram=7.6M "
|
||||
"last_upload=10s ota=registered",
|
||||
"ESP-ROM:esp32s3-20210327", "rst:0xf (RTCWDT_BROWN_OUT_RESET),boot:0x8",
|
||||
I2C_OK, BOOT.format(last="brownout")])
|
||||
assert r.roots[0].finding.rule_id == "reboot.brownout"
|
||||
|
||||
|
||||
def test_uncaptured_error_id_uses_the_csv_action():
|
||||
_, r = run(BANNER + [I2C_OK, "E (3000) INFLUX_BUFFER: [E-INF-13] Failed to open buffer file for appending"])
|
||||
f = next(f for rc in r.roots for f in [rc.finding, *rc.consequences] if "E-INF-13" in f.error_ids)
|
||||
# A rule already knows this message, so the rule finding carries the code.
|
||||
assert f.rule_id == "sd.write_fail"
|
||||
_, r = run(BANNER + [I2C_OK, "E (3000) SPI: [E-SPI-01] Failed to take SPI mutex"])
|
||||
f = roots(r)["eid.SPI"].finding
|
||||
assert f.title == "SPI bus: Failed to take SPI mutex"
|
||||
assert "firmware team" in f.actions[0] and f.error_ids == {"E-SPI-01": f.actions[0]}
|
||||
|
||||
|
||||
def test_legacy_rules_still_match_lines_with_error_ids():
|
||||
_, r = run(BANNER + ["E (1200) MAIN: [E-MAIN-13] Failed to initialize RTC: ESP_ERR_TIMEOUT"])
|
||||
assert "rtc.init_failed" in roots(r)
|
||||
|
||||
|
||||
def test_health_line_gives_status_when_connected_mid_run():
|
||||
e, r = run(["I (600000) KOTA: health up=600s status=CONNECTION_ERROR wifi=disconnected rssi=0 "
|
||||
"time_valid=1 sd=ok buffer=120 heap=150000 psram=7.6M last_upload=never ota=registered",
|
||||
"I (660000) KOTA: health up=660s status=CONNECTION_ERROR wifi=disconnected rssi=0 "
|
||||
"time_valid=1 sd=ok buffer=130 heap=150000 psram=7.6M last_upload=never ota=registered"])
|
||||
assert (r.led, r.led_pattern) == ("PURPLE", "solid") and not r.led_exact
|
||||
rs = roots(r)
|
||||
assert "kota.wifi_down" in rs
|
||||
assert "status.conn" in {c.rule_id for c in rs["kota.wifi_down"].consequences}
|
||||
assert r.mid_run
|
||||
|
||||
|
||||
def test_sd_and_rtc_self_report():
|
||||
_, r = run(BANNER + [
|
||||
"I (1795) KOTA: rtc running=0 time=2000-01-01T00:00:00",
|
||||
"I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL buffer_lines=0",
|
||||
"I (8100) KOTA: rtc verify running=0 delta=0s ok=0",
|
||||
])
|
||||
ids = {f.key for rc in r.roots for f in [rc.finding, *rc.consequences]}
|
||||
assert {"influx.env_missing:INFLUX_TOKEN", "kota.rtc_stopped", "kota.rtc_not_ticking"} <= ids
|
||||
_, r = run(BANNER + ["I (1990) KOTA: sd mounted=0 err=ESP_ERR_TIMEOUT",
|
||||
"I (1991) KOTA: status OK -> SD_ERROR reason=sd_mount_failed"])
|
||||
rs = roots(r)
|
||||
assert "kota.sd_unmounted" in rs and "ESP_ERR_TIMEOUT" in rs["kota.sd_unmounted"].finding.title
|
||||
assert "status.sd" in {c.rule_id for c in rs["kota.sd_unmounted"].consequences}
|
||||
|
||||
|
||||
def test_matter_and_mic_lines():
|
||||
e, r = run(BANNER + ["I (2315) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332",
|
||||
"I (2400) KOTA: mic enabled=0 (GPIO3=1, expected 0)",
|
||||
"I (4481) KOTA: led color=cyan pattern=breathe period=3000ms"])
|
||||
rs = roots(r)
|
||||
assert "matter.not_commissioned" in rs and "mic.disabled" in rs
|
||||
assert (r.led, r.led_meaning) == ("CYAN", "waiting to be paired in Matter")
|
||||
p = payload_from_log([], e.matter_qr, e.matter_manual)
|
||||
assert p.qr_text() == "MT:Y.K9042C00KA0648G00" and p.manual_code_pretty() == "3497-011-2332"
|
||||
_, r = run(BANNER + ["I (2315) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332",
|
||||
"I (90000) KOTA: matter fabrics=1 window=closed qr=MT:Y.K9042C00KA0648G00 manual=34970112332"])
|
||||
assert "matter.not_commissioned" not in roots(r)
|
||||
|
||||
|
||||
def test_build_prefixes_of_different_length_match():
|
||||
full = "4f10989e2b40c1d29fb3e8f8faaa50e5555997909c6d6b1db9d4e1e4d04555dc"
|
||||
assert same_build("4f10989e", full) and same_build(full, "4f10989e2")
|
||||
assert not same_build("4f10989f", full) and not same_build(None, full)
|
||||
|
||||
|
||||
def test_live_fw1016_uncommissioned_box():
|
||||
# Real capture (2026-09-28) after flashing 1.0.16 App-only: not paired in Matter.
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(FIX / "live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt"):
|
||||
e.feed(ln)
|
||||
r = e.report()
|
||||
assert r.firmware.version == "1.0.16" and same_build(r.firmware.elf_sha, "4f10989e")
|
||||
assert (r.led, r.led_pattern, r.led_exact) == ("CYAN", "breathing", True)
|
||||
rs = roots(r)
|
||||
assert r.roots[0].finding.rule_id == "matter.not_commissioned"
|
||||
grouped = {c.rule_id for c in rs["matter.not_commissioned"].consequences}
|
||||
assert {"kota.wifi_down", "net.dns_fail", "status.conn", "kota.buffer_backlog"} <= grouped
|
||||
assert "rtc.clock_not_running" in {c.rule_id for c in rs["kota.rtc_stopped"].consequences}
|
||||
assert e.matter_qr == "MT:Y.K9042C00KA0648G00"
|
||||
assert not any(f.rule_id.startswith("other.") for f in e.findings.values())
|
||||
@@ -86,6 +86,46 @@ def test_hard_reset_pushes_rts_via_dtr_write(monkeypatch):
|
||||
assert all(c[1] is False for c in ser.calls if c[0] == "dtr")
|
||||
|
||||
|
||||
class _PosixOpenSerial:
|
||||
"""pyserial on macOS/Linux: the OS asserts both lines on open, then
|
||||
pyserial writes the requested DTR, then RTS. Records every line state."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.port = args[0] if args else "fake"
|
||||
self.dtr = self.rts = True # pyserial defaults
|
||||
self.states = []
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
object.__setattr__(self, name, value)
|
||||
if name in ("dtr", "rts") and getattr(self, "is_open", False):
|
||||
self.states.append((self.dtr, self.rts))
|
||||
|
||||
def open(self):
|
||||
want_dtr, want_rts = self.dtr, self.rts
|
||||
object.__setattr__(self, "dtr", True)
|
||||
object.__setattr__(self, "rts", True)
|
||||
self.is_open = True
|
||||
self.states.append((True, True))
|
||||
self.dtr = want_dtr
|
||||
self.rts = want_rts
|
||||
|
||||
|
||||
def test_open_on_posix_never_passes_through_reset(monkeypatch):
|
||||
monkeypatch.setattr(mgr_mod.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(mgr_mod, "find_port", lambda sn, dev: LOOP)
|
||||
opened = []
|
||||
monkeypatch.setattr(
|
||||
mgr_mod.serial, "serial_for_url",
|
||||
lambda *a, **k: opened.append(_PosixOpenSerial(*a, **k)) or opened[-1],
|
||||
)
|
||||
m = SerialManager(lambda ln: None, lambda s, d: None)
|
||||
m._target = mgr_mod._Target(LOOP.device, None)
|
||||
ser = m._open()
|
||||
# (DTR, RTS) = (False, True) resets the chip on connect.
|
||||
assert (False, True) not in ser.states
|
||||
assert ser.states[-1] == (False, False)
|
||||
|
||||
|
||||
def test_waits_when_port_missing(monkeypatch):
|
||||
monkeypatch.setattr(mgr_mod, "find_port", lambda sn, dev: None)
|
||||
states, lines = [], []
|
||||
|
||||
@@ -12,3 +12,15 @@ def test_other_ports_not_sensorbox():
|
||||
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
|
||||
assert not bt.is_sensorbox and bt.mac is None
|
||||
assert bt.label == "COM3 - Standard Serial over Bluetooth link"
|
||||
|
||||
|
||||
def test_macos_lists_only_callout_devices(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from kota_tech.serialio import ports
|
||||
fake = [SimpleNamespace(device=d, description="USB JTAG/serial debug unit", vid=0x303A, pid=0x1001,
|
||||
serial_number="A4:CB:8F:C9:46:F8")
|
||||
for d in ("/dev/tty.usbmodem1101", "/dev/cu.usbmodem1101")]
|
||||
monkeypatch.setattr(ports.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(ports.list_ports, "comports", lambda: fake)
|
||||
assert [p.device for p in ports.list_serial_ports()] == ["/dev/cu.usbmodem1101"]
|
||||
@@ -170,3 +170,14 @@ def test_elevated_result_folder_is_not_owner_only(tmp_path, monkeypatch):
|
||||
assert work.parent == tmp_path / "tmp" and work.is_dir()
|
||||
(work / "result.json").write_text('{"ok": true}', encoding="utf-8")
|
||||
assert (work / "result.json").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fs", ["FAT32", "DOS_FAT_32", "Windows_FAT_32", "MS-DOS FAT32"])
|
||||
def test_fat32_names_from_windows_and_macos_are_accepted(tmp_path, fs):
|
||||
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
|
||||
assert not any("File system" in p for p in check_card(str(tmp_path), fs).problems)
|
||||
|
||||
|
||||
def test_exfat_is_flagged(tmp_path):
|
||||
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
|
||||
assert any("File system" in p for p in check_card(str(tmp_path), "ExFAT").problems)
|
||||
@@ -33,7 +33,7 @@ def test_bench_box_is_a_connectivity_problem():
|
||||
assert service_status(e.report()) == CONNECTIVITY
|
||||
note = service_note(e.report())
|
||||
assert note.startswith("[KOTA Technician Tool")
|
||||
assert "not commissioned in Matter" in note and "-> No Internet" in note
|
||||
assert "not commissioned in Matter" in note and "↳ [Error] No Internet" in note
|
||||
assert "Firmware: v1.0" in note
|
||||
|
||||
|
||||
@@ -89,3 +89,19 @@ def test_invalid_mac_rejected(bad):
|
||||
|
||||
def test_normalize_mac():
|
||||
assert normalize_mac("a4:cb:8f:c9:46:f8") == "A4CB8FC946F8"
|
||||
|
||||
|
||||
def test_note_mirrors_the_diagnosis_panel():
|
||||
from pathlib import Path
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(Path(__file__).parent / "fixtures" / "live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt"):
|
||||
e.feed(ln)
|
||||
note = service_note(e.report())
|
||||
assert len(note) <= 5000
|
||||
for part in ("Status LED should be: Cyan, breathing", "Box self-report (KOTA lines):",
|
||||
"✔ I2C: found: AS7341, DS1307, SEN66, BME688", "Start-up progress (current boot):",
|
||||
"✔ SD card mounted (1.7 s)", "○ Connected to Wi-Fi", "What to do:",
|
||||
"Seen 12x, first at 21.6 s after start, last at 126.8 s",
|
||||
"↳ [Error] No Internet: server names cannot be resolved"):
|
||||
assert part in note, part
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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
|
||||
settings = [a.text() for m in w.menuBar().actions() for a in m.menu().actions()]
|
||||
assert "InfluxDB access..." in settings
|
||||
w.serial_tab.shutdown()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_serial_tab_checklist_and_test_strip():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from kota_tech.core.logline import parse_line
|
||||
from kota_tech.service import influx
|
||||
from kota_tech.ui.main_window import INFLUX_ROW, MainWindow
|
||||
app = QApplication.instance() or QApplication([])
|
||||
w = MainWindow()
|
||||
st = w.serial_tab
|
||||
assert st.test_btn.text().endswith("Test box")
|
||||
assert [st.side_tabs.tabText(i) for i in range(st.side_tabs.count())] == \
|
||||
["Live log", "Guided troubleshooting", "Box details"]
|
||||
st.influx_config = lambda: None # no token (never touch real settings)
|
||||
rows = st._cloud_rows()
|
||||
assert rows[0][0] == "off" and "not set up" in rows[0][1]
|
||||
st.influx_config = lambda: influx.InfluxConfig("t") # pretend access is set up
|
||||
assert st._cloud_rows()[0] == ("pending", f"{INFLUX_ROW} - press Test box")
|
||||
st._on_influx(influx.InfluxCheck(influx.SENSORS_MISSING, "Status arrives, sensor data does not",
|
||||
details=["SEN66 (air quality): no data"]))
|
||||
rows = st._cloud_rows()
|
||||
assert rows[0][0] == "bad" and rows[1][0] == "note"
|
||||
st._engine.feed(parse_line("ESP-ROM:esp32s3-20210327"))
|
||||
st._diag_dirty = True
|
||||
st._refresh_diag()
|
||||
texts = [st.diag.milestones.item(i).text() for i in range(st.diag.milestones.count())]
|
||||
assert any(INFLUX_ROW in t for t in texts)
|
||||
assert "Test box" in st.test_status.text()
|
||||
st.shutdown()
|
||||
app.processEvents()
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# KOTA Sensorbox Technician Tool - update to the newest released version.
|
||||
# Checks Gitea for the newest release tag (vX.Y) and downloads only what changed (git).
|
||||
# Works in a git clone and in a folder unpacked from a release zip (it is connected on first run).
|
||||
# If Finder refuses to open this file, run it once as: bash update_macos.command
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
REPO_URL="${KOTA_REPO_URL:-https://gitea.kota-app.com/kkallas/kota-technician-tool.git}"
|
||||
export UV_PROJECT_ENVIRONMENT="${UV_PROJECT_ENVIRONMENT:-$HOME/Library/Caches/KotaTechnicianTool/venv}"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
finish() {
|
||||
[ -z "$KOTA_NO_PAUSE" ] && [ -t 0 ] && read -r -p "Press Enter to close..." _
|
||||
exit "$1"
|
||||
}
|
||||
fail() { echo; echo "[!] $*"; echo "[!] Update did not finish."; finish 1; }
|
||||
|
||||
# ---- git (macOS: the Command Line Tools installer offers it) --------------
|
||||
if ! git --version >/dev/null 2>&1; then
|
||||
if [ "$(uname)" = Darwin ]; then
|
||||
xcode-select --install >/dev/null 2>&1
|
||||
fail "Git is being installed (Apple Command Line Tools). Run this again when the installer has finished."
|
||||
fi
|
||||
fail "Git is not installed."
|
||||
fi
|
||||
|
||||
# ---- connect a folder unpacked from a zip ----------------------------------
|
||||
if [ ! -d .git ]; then
|
||||
echo "Connecting this folder to $REPO_URL ..."
|
||||
git init -q && git remote add origin "$REPO_URL" || fail "Cannot set up git in this folder."
|
||||
fi
|
||||
|
||||
# A developer copy follows a branch; it is updated with "git pull", not here.
|
||||
if git rev-parse -q --verify HEAD >/dev/null 2>&1 && branch=$(git symbolic-ref -q --short HEAD); then
|
||||
echo "This is a developer copy on branch $branch. Update it with: git pull"
|
||||
finish 0
|
||||
fi
|
||||
|
||||
echo "Checking for a new version..."
|
||||
git fetch --quiet --force --tags origin ||
|
||||
fail "Cannot reach $REPO_URL. Check the Internet connection and your Gitea login, then try again."
|
||||
|
||||
latest=$(git tag -l 'v*' --sort=-v:refname | head -n 1)
|
||||
[ -n "$latest" ] || fail "No released version found on the server."
|
||||
current=$(git describe --tags --exact-match HEAD 2>/dev/null)
|
||||
|
||||
if [ "$current" = "$latest" ]; then
|
||||
echo "You already have the newest version $latest."
|
||||
else
|
||||
if [ -n "$current" ]; then
|
||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
echo "Some tool files in this folder were changed by hand. They will be replaced by $latest."
|
||||
read -r -p "Continue? [y/N] " answer
|
||||
[[ "$answer" =~ ^[Yy]$ ]] || fail "Cancelled."
|
||||
fi
|
||||
echo "Updating $current -> $latest. Changes:"
|
||||
git --no-pager log --oneline --no-decorate "$current..$latest"
|
||||
else
|
||||
echo "Installing $latest..."
|
||||
fi
|
||||
git -c advice.detachedHead=false checkout -q -f "$latest" || fail "Checkout of $latest failed."
|
||||
echo "Now on $latest."
|
||||
fi
|
||||
|
||||
# ---- Python libraries (only what changed) ----------------------------------
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "Installing uv (Python package runner, current user only)..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh || fail "uv installation failed. Ask the firmware team for help."
|
||||
fi
|
||||
echo "Checking Python libraries..."
|
||||
uv sync --frozen --no-dev --python 3.12 --quiet || fail "Installing Python libraries failed."
|
||||
echo
|
||||
echo "Done. Start the tool with run_macos.command."
|
||||
finish 0
|
||||
@@ -0,0 +1,107 @@
|
||||
@echo off
|
||||
rem KOTA Sensorbox Technician Tool - update to the newest released version.
|
||||
rem Checks Gitea for the newest release tag (vX.Y) and downloads only what changed (git).
|
||||
rem Works in a git clone and in a folder unpacked from a release zip (it is connected on first run).
|
||||
setlocal EnableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
title KOTA Technician Tool - update
|
||||
|
||||
if not defined KOTA_REPO_URL set "KOTA_REPO_URL=https://gitea.kota-app.com/kkallas/kota-technician-tool.git"
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
|
||||
set "PATH=%USERPROFILE%\.local\bin;%ProgramFiles%\Git\cmd;%PATH%"
|
||||
|
||||
rem ---- git ----------------------------------------------------------------
|
||||
where git >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Git is not installed. It is needed to download updates.
|
||||
choice /m "Install Git for Windows now"
|
||||
if errorlevel 2 goto :fail
|
||||
winget install --id Git.Git -e --source winget --accept-package-agreements
|
||||
where git >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Git installation failed. Install it from https://git-scm.com and run this again.
|
||||
goto :fail
|
||||
)
|
||||
)
|
||||
|
||||
rem ---- connect a folder unpacked from a zip ---------------------------------
|
||||
if not exist ".git" (
|
||||
echo Connecting this folder to %KOTA_REPO_URL% ...
|
||||
git init -q || goto :fail
|
||||
git remote add origin "%KOTA_REPO_URL%" || goto :fail
|
||||
)
|
||||
|
||||
rem A developer copy follows a branch; it is updated with "git pull", not here.
|
||||
git rev-parse -q --verify HEAD >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
for /f "delims=" %%b in ('git symbolic-ref -q --short HEAD 2^>nul') do (
|
||||
echo This is a developer copy on branch %%b. Update it with: git pull
|
||||
goto :done
|
||||
)
|
||||
)
|
||||
|
||||
echo Checking for a new version...
|
||||
git fetch --quiet --force --tags origin
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Cannot reach %KOTA_REPO_URL%.
|
||||
echo Check the Internet connection and your Gitea login, then try again.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
set "LATEST="
|
||||
for /f "delims=" %%t in ('git tag -l "v*" --sort^=-v:refname') do if not defined LATEST set "LATEST=%%t"
|
||||
if not defined LATEST (
|
||||
echo ERROR: No released version found on the server.
|
||||
goto :fail
|
||||
)
|
||||
set "CURRENT="
|
||||
for /f "delims=" %%t in ('git describe --tags --exact-match HEAD 2^>nul') do set "CURRENT=%%t"
|
||||
|
||||
if "%CURRENT%"=="%LATEST%" (
|
||||
echo You already have the newest version %LATEST%.
|
||||
goto :deps
|
||||
)
|
||||
|
||||
if defined CURRENT (
|
||||
for /f "delims=" %%s in ('git status --porcelain --untracked-files^=no') do set "DIRTY=1"
|
||||
if defined DIRTY (
|
||||
echo Some tool files in this folder were changed by hand. They will be replaced by %LATEST%.
|
||||
choice /m "Continue"
|
||||
if errorlevel 2 goto :fail
|
||||
)
|
||||
echo Updating %CURRENT% -^> %LATEST%. Changes:
|
||||
git --no-pager log --oneline --no-decorate "%CURRENT%..%LATEST%"
|
||||
) else (
|
||||
echo Installing %LATEST%...
|
||||
)
|
||||
git -c advice.detachedHead=false checkout -q -f "%LATEST%"
|
||||
if errorlevel 1 goto :fail
|
||||
echo Now on %LATEST%.
|
||||
|
||||
rem ---- Python libraries (only what changed) ----------------------------------
|
||||
:deps
|
||||
where uv >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing uv ^(Python package runner, current user only^)...
|
||||
powershell -NoProfile -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
)
|
||||
where uv >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: uv installation failed. Ask the firmware team for help.
|
||||
goto :fail
|
||||
)
|
||||
echo Checking Python libraries...
|
||||
uv sync --frozen --no-dev --python 3.12 --quiet
|
||||
if errorlevel 1 goto :fail
|
||||
echo.
|
||||
echo Done. Start the tool with run_windows.bat.
|
||||
|
||||
:done
|
||||
if not defined KOTA_NO_PAUSE pause
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo ERROR: Update did not finish.
|
||||
if not defined KOTA_NO_PAUSE pause
|
||||
exit /b 1
|
||||