15 Commits
Author SHA1 Message Date
kkallas 834bad2bce Merge branch 'docs/technician-guide' into main
Technician guide (Markdown + PDF) with screenshots; USB 2.0 type-A plug requirement in the guide, README and the app's hints.
2026-09-30 15:46:00 +02:00
kkallasandClaude Opus 5.5 c41fa101b6 Technician guide as PDF (docs/INSTRUKCJA_TECHNIKA.pdf)
Printable A4 version of the guide with the screenshots, linked from the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:45:51 +02:00
kkallasandClaude Opus 5.5 e299ef40f0 USB wording: a USB 2.0 type-A plug on the way; hubs are fine
Guide, README, quick start and flashing hints no longer say 'no hub' or 'directly to the laptop'; the requirement is a USB 2.0 type-A plug between box and computer (USB-C only is the failing case).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:41:12 +02:00
kkallasandClaude Opus 5.5 39f0d1718c Technician guide with screenshots; USB 2.0 type-A cable requirement
docs/INSTRUKCJA_TECHNIKA.md: install, update, connecting the box, reading
the Diagnosis panel, guided checks, Wi-Fi over USB, flashing, SD card,
service report, LED table and common problems, with screenshots of the
app rendered from a real v13 capture (docs/img). The box must be
connected with a USB 2.0 type-A cable directly to the computer; the
quick-start screen and README say so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:39:02 +02:00
kkallas 6a62beb6aa Merge branch 'feature/wifi-over-usb' into main
Wi-Fi over USB tab (firmware v13 usb_console), LF line endings for all text files, live v13 test capture.
2026-09-30 13:58:53 +02:00
kkallasandClaude Opus 5.5 5bd14e1d5b Test: live v13 box answers USB commands
Capture from box 14:C1:9F:57:72:48 after flashing merged-binary-v13.bin: ping and wifi get replies, logs intact through the USB-Serial-JTAG driver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:38:02 +02:00
kkallasandClaude Opus 5.5 ab420220e1 Wi-Fi over USB tab for firmware with the USB console (v13)
Writes / reads / removes the box's Wi-Fi over the serial port with the
usb_console commands (kota ping, wifi set|get|clear, health) and follows
the box through the restart until it joins the network. The password is
sent hex-encoded and never logged.

firmware/ now holds merged-binary-v13.bin (build 3ae273ec) instead of the
previous 1.0.16 build; error_ids.csv synced with the firmware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:30:35 +02:00
kkallasandClaude Opus 5.5 0d21c5c205 Store all text files with LF line endings
Some files had been saved with CRLF on Windows, so a one-line change
showed as a whole-file diff. .gitattributes now stores text as LF
(.bat stays CRLF for cmd, .command/.sh LF for macOS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:30:25 +02:00
kkallas 3fe45fa4d5 Merge pull request 'Feature/updater' (#2) from feature/updater into main
Reviewed-on: #2
2026-09-28 19:47:15 +02:00
kkallasandClaude Opus 5.5 1ab8af8fe2 README: fix two Windows paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:44:56 +02:00
kkallasandClaude Opus 5.5 bfbbd537d5 Updater messages without "!", README and changelog for v0.9
cmd drops "!" from messages when delayed expansion is on, so errors
read "ERROR:". README describes the two files per system and the
first install from a release zip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:43:50 +02:00
kkallasandClaude Opus 5.5 a33736742a Technician updater and one shared Python environment (v0.9.0)
update_windows.bat / update_macos.command: fetch release tags from Gitea,
move the folder to the newest vX.Y (git downloads only what changed),
connect a folder unpacked from a release zip on first run, install git/uv
when missing and sync the Python libraries. Developer copies on a branch
are left alone.

run_* and update_* share one environment (KotaTechnicianTool/venv)
instead of one per version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:42:25 +02:00
kkallas 724c2d62f9 Merge pull request 'README: install and update from Gitea instead of Google Drive folders' (#1) from docs/readme-gitea into main
Reviewed-on: #1
2026-09-28 19:36:35 +02:00
kkallasandClaude Opus 5.5 43f4c51aa4 README: install and update from Gitea instead of Google Drive folders
Describes the zip from Releases and git clone/pull, the macOS
executable-bit note for zips, and the branch/PR/tag workflow for the team.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:34:01 +02:00
kkallas bfa7edce6f Technician tool v0.8
Imported from the Google Drive folder 004-technician_tool/v0.8.
2026-09-28 19:19:55 +02:00
51 changed files with 10275 additions and 7982 deletions

No files matched your search

+2
View File
@@ -1,4 +1,6 @@
* text=auto eol=lf
*.command text eol=lf *.command text eol=lf
*.sh text eol=lf *.sh text eol=lf
*.bat text eol=crlf *.bat text eol=crlf
*.bin binary *.bin binary
*.png binary
+89
View File
@@ -0,0 +1,89 @@
# 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`).
## 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.
+14
View File
@@ -0,0 +1,14 @@
# Technician tool v0.10 - changes (draft)
- New tab **Wi-Fi (USB)**: write the Wi-Fi name and password to the box's SD card over the USB cable, without
opening the case (firmware usb_console, `kota wifi set`). Shows each step: command support check, stored,
restart in Wi-Fi mode, connected with signal strength (polls `kota health`). Also reads the stored network
(`kota wifi get`, password never shown) and removes it (`kota wifi clear`, back to Matter).
- The password is sent hex-encoded, never written to the session log or the service report.
- Diagnosis: restarts `usb_wifi_set` / `usb_wifi_clear` are intentional (no "unexpected restart"); box
self-report shows the stored Wi-Fi network; `cmd health` replies update the health line.
- firmware/: `merged-binary-v13.bin` (usb_console, build 3ae273ec) replaces `merged-binary.bin` (build 4f10989e,
still in git tag v0.9). Note: v13 reports the same version 1.0.16 and compile date as the previous build;
the firmware team should bump the version for the next build.
- error_ids.csv updated from the firmware (E-CON-01).
- Test that builds the main window (catches start-up errors).
+21
View File
@@ -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.
+9
View File
@@ -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`).
+204
View File
@@ -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:
![Quick start](img/01_quick_start.png)
## 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ę.
![Serial monitor i diagnoza](img/02_serial_diagnosis.png)
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**:
![Lista sprawdzeń](img/03_guided_list.png)
Program zadaje pytania (np. jaki kolor ma dioda) i na bieżąco sprawdza log boxa. Odpowiedzi trafiają do raportu:
![Pytanie o kolor diody](img/04_guided_led_question.png)
## 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)”**:
![Wi-Fi przez USB](img/06_wifi_usb.png)
- 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
![Wgrywanie firmware](img/05_flash.png)
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.
![Karta SD](img/07_sd_card.png)
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.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+35 -30
View File
@@ -1,30 +1,35 @@
You are testing the KOTA sensorbox technician tool v0.5 on macOS. The current folder is You are testing the KOTA sensorbox technician tool v0.8 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). 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. Rules:
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin. - Python only via uv or conda/mamba. Never use bare/system python or pip.
- Do not push anything to Git or GitLab. This is a local demo. - Do not build or modify firmware. Flash only files from the tool's firmware/ folder.
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files. - Do not push anything to Git or GitLab. This is a local demo.
Before formatting, show me the exact disk (identifier, name, size, current files). - Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
- Do not change code without telling me. Report bugs with evidence (commands and output). Before formatting, show me the exact disk (identifier, name, size, current files).
- Do not change code without telling me. Report bugs with evidence (commands and output).
Tests, in order. Report pass/fail with evidence for each:
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings. Tests, in order. Report pass/fail with evidence for each:
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q` 1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)" Also check whether a double-click in Finder works (Google Drive may drop the executable bit).
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the 2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8" uv run --frozen pytest -q`
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the (expect 172 passed).
reset and whether the tool reconnects. 3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows. entry with a MAC (no /dev/tty.* duplicate). Connect, press "Reset & capture": the log starts at
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself, "ESP-ROM:esp32s3" and the Diagnosis panel shows firmware v1.0.16 (build 4f10989e), the "Box self-report"
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output. section and the LED with its pattern. Check whether the port disappears after the reset and whether the
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed tool reconnects. Wait 2 minutes: a "KOTA: health" line appears every 60 s.
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT". 4. Show filter "KOTA diagnostics + errors" shows only KOTA lines and errors.
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical 5. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
to the template, and eject the card. 6. Flash (App only, the newest file in firmware/, chosen by its compile date): the tool must enter flashing mode
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine by itself, write, restart the box and report "Success ... build 4f10989e". Capture the esptool output.
if the page itself cannot prefill yet). 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
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT and eject the card.
suggested fixes (file:line). 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 -1
View File
@@ -1,3 +1,3 @@
"""KOTA sensorbox technician tool.""" """KOTA sensorbox technician tool."""
__version__ = "0.7.0" __version__ = "0.9.0"
+202 -202
View File
@@ -1,202 +1,202 @@
"""Serial byte stream -> framed, cleaned, parsed log lines. """Serial byte stream -> framed, cleaned, parsed log lines.
ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally
wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset
reason) has no level prefix and is classified separately, since a boot reason) has no level prefix and is classified separately, since a boot
banner is the only reliable reboot marker. banner is the only reliable reboot marker.
Firmware 1.0.16+ adds structured diagnostic lines logged under the "KOTA" 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") 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] "). 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 Both are parsed here so the diagnostics engine reads fields directly
instead of matching message text. instead of matching message text.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from .redact import redact from .redact import redact
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$") ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then # ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)". # "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:") BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)") 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). # "[E-SD-03] Failed to initialize the card ..." (firmware error_ids.csv).
ERROR_ID_RE = re.compile(r"^\[(E-[A-Z0-9]+-\d{2,})\] ?") ERROR_ID_RE = re.compile(r"^\[(E-[A-Z0-9]+-\d{2,})\] ?")
KOTA_TAG = "KOTA" KOTA_TAG = "KOTA"
PANIC_RE = re.compile( PANIC_RE = re.compile(
r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|" r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|"
r"^Backtrace:|Rebooting\.\.\.$" r"^Backtrace:|Rebooting\.\.\.$"
) )
class LineKind(str, Enum): class LineKind(str, Enum):
LOG = "log" # ESP_LOGx line LOG = "log" # ESP_LOGx line
BOOT = "boot" # ROM boot banner (start of a new boot) BOOT = "boot" # ROM boot banner (start of a new boot)
RESET = "reset" # ROM reset-reason line RESET = "reset" # ROM reset-reason line
PANIC = "panic" # crash / abort output PANIC = "panic" # crash / abort output
KOTA = "kota" # structured "KOTA: <record> key=value ..." line KOTA = "kota" # structured "KOTA: <record> key=value ..." line
RAW = "raw" # anything else (task list rows, line protocol, ...) RAW = "raw" # anything else (task list rows, line protocol, ...)
HOST = "host" # event generated by this tool (connect, reset, ...) HOST = "host" # event generated by this tool (connect, reset, ...)
def host_event(text: str) -> LogLine: def host_event(text: str) -> LogLine:
return LogLine(f"--- {text} ---", LineKind.HOST) return LogLine(f"--- {text} ---", LineKind.HOST)
@dataclass(frozen=True) @dataclass(frozen=True)
class LogLine: class LogLine:
text: str # cleaned + redacted text: str # cleaned + redacted
kind: LineKind kind: LineKind
host_time: float = field(default_factory=time.time) host_time: float = field(default_factory=time.time)
level: str | None = None # E/W/I/D/V for LOG lines level: str | None = None # E/W/I/D/V for LOG lines
uptime_ms: int | None = None uptime_ms: int | None = None
tag: str | None = None tag: str | None = None
message: str | None = None message: str | None = None
reset_reason: str | None = None reset_reason: str | None = None
error_id: str | None = None # "E-SD-03" when the message carries one error_id: str | None = None # "E-SD-03" when the message carries one
record: str | None = None # KOTA lines: "boot", "i2c", "health", ... record: str | None = None # KOTA lines: "boot", "i2c", "health", ...
fields: dict[str, str] = field(default_factory=dict) # KOTA key=value pairs fields: dict[str, str] = field(default_factory=dict) # KOTA key=value pairs
words: tuple[str, ...] = () # KOTA bare words ("verify", "OK", "->", ...) words: tuple[str, ...] = () # KOTA bare words ("verify", "OK", "->", ...)
@property @property
def match_text(self) -> str: def match_text(self) -> str:
"""Text for message rules: the error-ID prefix removed, so rules """Text for message rules: the error-ID prefix removed, so rules
written for older firmware keep matching.""" written for older firmware keep matching."""
if self.error_id and self.message is not None and self.tag: 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.replace(f"[{self.error_id}] ", "", 1)
return self.text return self.text
def parse_kota(body: str) -> tuple[str, dict[str, str], tuple[str, ...]]: def parse_kota(body: str) -> tuple[str, dict[str, str], tuple[str, ...]]:
"""'rtc verify running=1 delta=2s ok=1' -> ('rtc', {...}, ('verify',)). """'rtc verify running=1 delta=2s ok=1' -> ('rtc', {...}, ('verify',)).
Values never contain spaces. The mic line has a parenthesised aside Values never contain spaces. The mic line has a parenthesised aside
("(GPIO3=1, expected 1)"): brackets and commas are stripped from keys ("(GPIO3=1, expected 1)"): brackets and commas are stripped from keys
and values, and the aside's words are kept as bare words. and values, and the aside's words are kept as bare words.
""" """
tokens = body.split() tokens = body.split()
if not tokens: if not tokens:
return "", {}, () return "", {}, ()
fields: dict[str, str] = {} fields: dict[str, str] = {}
words: list[str] = [] words: list[str] = []
for tok in tokens[1:]: for tok in tokens[1:]:
key, eq, value = tok.partition("=") key, eq, value = tok.partition("=")
if eq and key.strip("(,"): if eq and key.strip("(,"):
key = key.lstrip("(") key = key.lstrip("(")
# "0x68=DS1307(missing)" keeps its bracket; "(GPIO3=1," loses it. # "0x68=DS1307(missing)" keeps its bracket; "(GPIO3=1," loses it.
value = value.rstrip(",") value = value.rstrip(",")
if tok.startswith("(") or value.endswith(")") and "(" not in value: if tok.startswith("(") or value.endswith(")") and "(" not in value:
value = value.rstrip(")") value = value.rstrip(")")
fields[key] = value fields[key] = value
else: else:
words.append(tok.strip("(),")) words.append(tok.strip("(),"))
return tokens[0], fields, tuple(w for w in words if w) return tokens[0], fields, tuple(w for w in words if w)
def clean(raw: str) -> str: def clean(raw: str) -> str:
return ANSI_RE.sub("", raw).replace("\r", "").rstrip() return ANSI_RE.sub("", raw).replace("\r", "").rstrip()
def parse_line(raw: str, host_time: float | None = None) -> LogLine: def parse_line(raw: str, host_time: float | None = None) -> LogLine:
text = redact(clean(raw)) text = redact(clean(raw))
t = time.time() if host_time is None else host_time t = time.time() if host_time is None else host_time
if m := ESP_LOG_RE.match(text): if m := ESP_LOG_RE.match(text):
level, ms, tag, msg = m.groups() level, ms, tag, msg = m.groups()
tag = tag.strip() tag = tag.strip()
if tag == KOTA_TAG: if tag == KOTA_TAG:
record, fields, words = parse_kota(msg) record, fields, words = parse_kota(msg)
return LogLine(text, LineKind.KOTA, t, level, int(ms), tag, msg, return LogLine(text, LineKind.KOTA, t, level, int(ms), tag, msg,
record=record, fields=fields, words=words) record=record, fields=fields, words=words)
eid = None eid = None
if em := ERROR_ID_RE.match(msg): if em := ERROR_ID_RE.match(msg):
eid, msg = em.group(1), msg[em.end():] eid, msg = em.group(1), msg[em.end():]
return LogLine(text, LineKind.LOG, t, level, int(ms), tag, msg, error_id=eid) return LogLine(text, LineKind.LOG, t, level, int(ms), tag, msg, error_id=eid)
if BOOT_BANNER_RE.match(text): if BOOT_BANNER_RE.match(text):
return LogLine(text, LineKind.BOOT, t) return LogLine(text, LineKind.BOOT, t)
if m := RESET_REASON_RE.match(text): if m := RESET_REASON_RE.match(text):
return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2)) return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2))
if PANIC_RE.search(text): if PANIC_RE.search(text):
return LogLine(text, LineKind.PANIC, t) return LogLine(text, LineKind.PANIC, t)
return LogLine(text, LineKind.RAW, t) return LogLine(text, LineKind.RAW, t)
class LineFramer: class LineFramer:
"""Accumulates bytes and yields complete lines. """Accumulates bytes and yields complete lines.
A partial line is released after `idle_flush_s` without new bytes so A partial line is released after `idle_flush_s` without new bytes so
prompts / unterminated output are not held back forever. prompts / unterminated output are not held back forever.
Lines are also split where a new log line starts in the middle Lines are also split where a new log line starts in the middle
("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores ("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old
line while no host reads, releasing it glued in front of the next live line while no host reads, releasing it glued in front of the next live
output. After a (re)open, the fragment in front of the first such split output. After a (re)open, the fragment in front of the first such split
is stale and dropped. Timing cannot identify it, because the FIFO only is stale and dropped. Timing cannot identify it, because the FIFO only
flushes when the board next prints, possibly seconds later. flushes when the board next prints, possibly seconds later.
""" """
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096, def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
drop_first_partial: bool = False): drop_first_partial: bool = False):
self._buf = bytearray() self._buf = bytearray()
self._last_rx = 0.0 self._last_rx = 0.0
self.idle_flush_s = idle_flush_s self.idle_flush_s = idle_flush_s
self.max_line = max_line self.max_line = max_line
self._first_line_pending = drop_first_partial self._first_line_pending = drop_first_partial
def cancel_drop(self) -> None: def cancel_drop(self) -> None:
self._first_line_pending = False self._first_line_pending = False
def feed(self, data: bytes, now: float | None = None) -> list[str]: def feed(self, data: bytes, now: float | None = None) -> list[str]:
now = time.monotonic() if now is None else now now = time.monotonic() if now is None else now
if data: if data:
self._buf.extend(data) self._buf.extend(data)
self._last_rx = now self._last_rx = now
out: list[str] = [] out: list[str] = []
while (i := self._buf.find(b"\n")) >= 0: while (i := self._buf.find(b"\n")) >= 0:
out += self._emit(self._buf[:i]) out += self._emit(self._buf[:i])
del self._buf[: i + 1] del self._buf[: i + 1]
if len(self._buf) >= self.max_line: if len(self._buf) >= self.max_line:
out += self._emit(self._buf) out += self._emit(self._buf)
self._buf.clear() self._buf.clear()
return out return out
def poll_idle(self, now: float | None = None) -> list[str]: def poll_idle(self, now: float | None = None) -> list[str]:
now = time.monotonic() if now is None else now now = time.monotonic() if now is None else now
if self._buf and now - self._last_rx >= self.idle_flush_s: if self._buf and now - self._last_rx >= self.idle_flush_s:
return self.flush() return self.flush()
return [] return []
def flush(self) -> list[str]: def flush(self) -> list[str]:
if not self._buf: if not self._buf:
return [] return []
out = self._emit(self._buf) out = self._emit(self._buf)
self._buf.clear() self._buf.clear()
return out return out
def _emit(self, raw: bytes | bytearray) -> list[str]: def _emit(self, raw: bytes | bytearray) -> list[str]:
parts = split_glued(bytes(raw).decode("utf-8", errors="replace")) parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
if self._first_line_pending: if self._first_line_pending:
self._first_line_pending = False self._first_line_pending = False
if len(parts) > 1: if len(parts) > 1:
parts = parts[1:] # stale FIFO fragment from before the open parts = parts[1:] # stale FIFO fragment from before the open
return parts return parts
# Start of a log line (optionally ANSI-coloured) or ROM banner. # Start of a log line (optionally ANSI-coloured) or ROM banner.
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)") _LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)")
def split_glued(text: str) -> list[str]: def split_glued(text: str) -> list[str]:
# Left-to-right scan so an ANSI colour prefix stays with its line. # Left-to-right scan so an ANSI colour prefix stays with its line.
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0] cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0]
if not cuts: if not cuts:
return [text] return [text]
bounds = [0, *cuts, len(text)] bounds = [0, *cuts, len(text)]
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b] return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b]
+1021 -999
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -191,3 +191,4 @@ E-GATH-01,GATH,error,Failed to take line protocol mutex,Internal error - send th
E-GATH-02,GATH,error,Failed to get line protocol from sensor,Device ran out of memory; restart it and send the log to the firmware team E-GATH-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-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-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 id module severity message action
191 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
192 E-GATH-03 GATH error Failed to create task for sensor Internal error - send the log to the firmware team
193 E-GATH-04 GATH error Failed to create main gather task Internal error - send the log to the firmware team
194 E-CON-01 CON error Failed to install USB-Serial-JTAG driver: %s Internal error - send the log to the firmware team
+147 -147
View File
@@ -1,147 +1,147 @@
"""Firmware identification from the boot log. """Firmware identification from the boot log.
Up to firmware 1.0 the app version string was "1.0" in every build, so 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 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 receive new firmware over OTA; an unknown SHA usually means a box has already
updated. updated.
Firmware 1.0.16+ prints "KOTA: boot fw=1.0.16 build=4f10989e ..." (version 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 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 tool's firmware/ folder are registered with `register_local_build` so the
box's build can be shown with its compile date. box's build can be shown with its compile date.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
@dataclass(frozen=True) @dataclass(frozen=True)
class KnownFirmware: class KnownFirmware:
name: str name: str
source: str source: str
sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT
setup_portal: bool # short press -> Wi-Fi setup portal setup_portal: bool # short press -> Wi-Fi setup portal
KNOWN: dict[str, KnownFirmware] = { KNOWN: dict[str, KnownFirmware] = {
"45fa9d5b9": KnownFirmware( "45fa9d5b9": KnownFirmware(
name="v1.0 (built Jun 29 2026)", name="v1.0 (built Jun 29 2026)",
source="factory image new_system.bin", source="factory image new_system.bin",
sd_wifi=False, sd_wifi=False,
setup_portal=False, setup_portal=False,
), ),
"c94ba0e9b": KnownFirmware( "c94ba0e9b": KnownFirmware(
name="v1.0 source build (Sep 26 2026)", name="v1.0 source build (Sep 26 2026)",
source="developer build, built by Krzysztof Kallas", source="developer build, built by Krzysztof Kallas",
sd_wifi=True, sd_wifi=True,
setup_portal=True, setup_portal=True,
), ),
} }
# elf sha (full or prefix) -> (version, compile time, file name) of builds in firmware/ # elf sha (full or prefix) -> (version, compile time, file name) of builds in firmware/
LOCAL_BUILDS: dict[str, tuple[str, str, str]] = {} LOCAL_BUILDS: dict[str, tuple[str, str, str]] = {}
def register_local_build(elf_sha: str, version: str, built: str, file_name: str) -> None: def register_local_build(elf_sha: str, version: str, built: str, file_name: str) -> None:
LOCAL_BUILDS[elf_sha.lower()] = (version, built, file_name) LOCAL_BUILDS[elf_sha.lower()] = (version, built, file_name)
def same_build(a: str | None, b: str | None) -> bool: def same_build(a: str | None, b: str | None) -> bool:
"""ELF SHA prefixes of different lengths (8 from KOTA: boot, 9+ from """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.""" app_init, 64 from an image) name the same build if they agree."""
if not a or not b: if not a or not b:
return False return False
n = min(len(a), len(b)) n = min(len(a), len(b))
return n >= 6 and a[:n].lower() == b[:n].lower() return n >= 6 and a[:n].lower() == b[:n].lower()
def local_build(elf_sha: str | None) -> tuple[str, str, str] | None: 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) 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,})") _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+)") _VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)")
_BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$") _BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$")
_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)") _IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)")
# Evidence of features, for builds not in KNOWN. # Evidence of features, for builds not in KNOWN.
_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ") _FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ")
_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ") _FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ")
# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag. # Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag.
_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted") _LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted")
@dataclass @dataclass
class FirmwareInfo: class FirmwareInfo:
elf_sha: str | None = None elf_sha: str | None = None
version: str | None = None version: str | None = None
built: str | None = None built: str | None = None
idf: str | None = None idf: str | None = None
hw: str | None = None hw: str | None = None
evidence: set[str] = field(default_factory=set) evidence: set[str] = field(default_factory=set)
def observe_kota(self, fields: dict[str, str]) -> None: def observe_kota(self, fields: dict[str, str]) -> None:
"""'KOTA: boot fw=1.0.16 build=4f10989e hw=2 ...'""" """'KOTA: boot fw=1.0.16 build=4f10989e hw=2 ...'"""
if fields.get("fw"): if fields.get("fw"):
self.version = fields["fw"] self.version = fields["fw"]
build = fields.get("build", "") build = fields.get("build", "")
# app_init's longer prefix is kept if it agrees. # app_init's longer prefix is kept if it agrees.
if build and not same_build(self.elf_sha, build): if build and not same_build(self.elf_sha, build):
self.elf_sha = build.lower() self.elf_sha = build.lower()
if fields.get("hw"): if fields.get("hw"):
self.hw = fields["hw"] self.hw = fields["hw"]
self.evidence.add("kota") # 1.0.16+: SD Wi-Fi and setup portal exist self.evidence.add("kota") # 1.0.16+: SD Wi-Fi and setup portal exist
def observe(self, text: str) -> None: def observe(self, text: str) -> None:
if m := _ELF.search(text): if m := _ELF.search(text):
self.elf_sha = m.group("sha") self.elf_sha = m.group("sha")
elif m := _VER.search(text): elif m := _VER.search(text):
self.version = m.group("v") self.version = m.group("v")
elif m := _BUILT.search(text): elif m := _BUILT.search(text):
self.built = m.group("t").strip() self.built = m.group("t").strip()
elif m := _IDF.search(text): elif m := _IDF.search(text):
self.idf = m.group("v") self.idf = m.group("v")
if _FEATURE_SD_WIFI.search(text): if _FEATURE_SD_WIFI.search(text):
self.evidence.add("sd_wifi") self.evidence.add("sd_wifi")
if _FEATURE_PORTAL.search(text): if _FEATURE_PORTAL.search(text):
self.evidence.add("setup_portal") self.evidence.add("setup_portal")
if _LEGACY_SD_TAG.search(text): if _LEGACY_SD_TAG.search(text):
self.evidence.add("legacy_v1") self.evidence.add("legacy_v1")
@property @property
def known(self) -> KnownFirmware | None: def known(self) -> KnownFirmware | None:
if not self.elf_sha: if not self.elf_sha:
return None return None
return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None) return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None)
@property @property
def supports_sd_wifi(self) -> bool | None: def supports_sd_wifi(self) -> bool | None:
"""True/False when determinable, None when unknown (no boot seen).""" """True/False when determinable, None when unknown (no boot seen)."""
if k := self.known: if k := self.known:
return k.sd_wifi return k.sd_wifi
if "sd_wifi" in self.evidence or "kota" in self.evidence: if "sd_wifi" in self.evidence or "kota" in self.evidence:
return True return True
if "legacy_v1" in self.evidence: if "legacy_v1" in self.evidence:
return False return False
return None return None
def describe(self) -> str: def describe(self) -> str:
if k := self.known: if k := self.known:
return f"{k.name} - {k.source}" return f"{k.name} - {k.source}"
if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)): if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)):
local = local_build(self.elf_sha) local = local_build(self.elf_sha)
built = self.built or (local[1] if local else None) 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"v{self.version or (local[0] if local else '?')} (build {self.elf_sha[:8]}"
text += f", compiled {built})" if built else ")" text += f", compiled {built})" if built else ")"
if self.hw: if self.hw:
text += f", hardware {self.hw}" text += f", hardware {self.hw}"
if local: if local:
text += f" - same as firmware/{local[2]}" text += f" - same as firmware/{local[2]}"
return text return text
if self.elf_sha: if self.elf_sha:
built = f", built {self.built}" if self.built else "" built = f", built {self.built}" if self.built else ""
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)" return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
if "legacy_v1" in self.evidence: if "legacy_v1" in self.evidence:
return "Probably v1.0 factory image (press Reset & capture to confirm)" return "Probably v1.0 factory image (press Reset & capture to confirm)"
return "Not identified yet - press Reset & capture" return "Not identified yet - press Reset & capture"
+401 -401
View File
@@ -1,401 +1,401 @@
"""Guided troubleshooting: executes the decision trees in guides.toml. """Guided troubleshooting: executes the decision trees in guides.toml.
GUI-independent. The runner is fed the same LogLines as the diagnostics 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 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 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 records every answer and conclusion in the engine so they appear in the
report and the session log. report and the session log.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
import time import time
import tomllib import tomllib
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from ..core.logline import LogLine from ..core.logline import LogLine
from .engine import LED, DiagnosticsEngine, Severity from .engine import LED, DiagnosticsEngine, Severity
GUIDES_PATH = Path(__file__).with_name("guides.toml") GUIDES_PATH = Path(__file__).with_name("guides.toml")
# LED answers a technician can give (engine colours + a reboot-loop pattern). # LED answers a technician can give (engine colours + a reboot-loop pattern).
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "CYAN", "OFF", "CYCLING"] LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "CYAN", "OFF", "CYCLING"]
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()}, LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
"CYCLING": "Keeps changing / goes dark every few seconds"} "CYCLING": "Keeps changing / goes dark every few seconds"}
STEP_TYPES = {"led", "question", "info", "wait", "route"} STEP_TYPES = {"led", "question", "info", "wait", "route"}
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING, END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
"error": Severity.ERROR, "critical": Severity.CRITICAL} "error": Severity.ERROR, "critical": Severity.CRITICAL}
@dataclass(frozen=True) @dataclass(frozen=True)
class Option: class Option:
label: str label: str
next: str next: str
@dataclass(frozen=True) @dataclass(frozen=True)
class Step: class Step:
id: str id: str
type: str type: str
text: str = "" text: str = ""
options: tuple[Option, ...] = () options: tuple[Option, ...] = ()
route: dict[str, str] = field(default_factory=dict) # led colour -> target route: dict[str, str] = field(default_factory=dict) # led colour -> target
routes: dict[str, str] = field(default_factory=dict) # rule id -> target routes: dict[str, str] = field(default_factory=dict) # rule id -> target
default: str | None = None default: str | None = None
next: str | None = None next: str | None = None
tool_action: str | None = None tool_action: str | None = None
until: re.Pattern[str] | None = None until: re.Pattern[str] | None = None
until_milestone: str | None = None until_milestone: str | None = None
fail_on: re.Pattern[str] | None = None fail_on: re.Pattern[str] | None = None
timeout_s: float = 60.0 timeout_s: float = 60.0
pass_on_timeout: bool = False pass_on_timeout: bool = False
on_ok: str | None = None on_ok: str | None = None
on_fail: str | None = None on_fail: str | None = None
on_timeout: str | None = None on_timeout: str | None = None
skip_if_finding: str | None = None skip_if_finding: str | None = None
skip_unless_feature: str | None = None skip_unless_feature: str | None = None
skip_to: str | None = None skip_to: str | None = None
show_qr: bool = False # show the Matter pairing QR next to the step text show_qr: bool = False # show the Matter pairing QR next to the step text
def targets(self) -> list[str]: def targets(self) -> list[str]:
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values()) t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
t += [x for x in (self.default, self.next, self.on_ok, self.on_fail, self.on_timeout, self.skip_to) if x] 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 return t
@dataclass(frozen=True) @dataclass(frozen=True)
class End: class End:
id: str id: str
severity: str severity: str
title: str title: str
text: str text: str
actions: tuple[str, ...] = () actions: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class Guide: class Guide:
id: str id: str
title: str title: str
summary: str summary: str
recommend: tuple[str, ...] recommend: tuple[str, ...]
start: str start: str
steps: dict[str, Step] steps: dict[str, Step]
ends: dict[str, End] ends: dict[str, End]
def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]: def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
data = tomllib.loads(path.read_text(encoding="utf-8")) data = tomllib.loads(path.read_text(encoding="utf-8"))
guides: dict[str, Guide] = {} guides: dict[str, Guide] = {}
for g in data["guide"]: for g in data["guide"]:
steps = {} steps = {}
for s in g.get("step", []): for s in g.get("step", []):
steps[s["id"]] = Step( steps[s["id"]] = Step(
id=s["id"], type=s["type"], text=s.get("text", ""), id=s["id"], type=s["type"], text=s.get("text", ""),
options=tuple(Option(o["label"], o["next"]) for o in s.get("options", ())), options=tuple(Option(o["label"], o["next"]) for o in s.get("options", ())),
route=dict(s.get("route", {})), routes=dict(s.get("routes", {})), route=dict(s.get("route", {})), routes=dict(s.get("routes", {})),
default=s.get("default"), next=s.get("next"), tool_action=s.get("tool_action"), default=s.get("default"), next=s.get("next"), tool_action=s.get("tool_action"),
until=re.compile(s["until"]) if "until" in s else None, until=re.compile(s["until"]) if "until" in s else None,
until_milestone=s.get("until_milestone"), until_milestone=s.get("until_milestone"),
fail_on=re.compile(s["fail_on"]) if "fail_on" in s else None, fail_on=re.compile(s["fail_on"]) if "fail_on" in s else None,
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)), timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"), on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"), skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)), skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
) )
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ()))) ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
for e in g.get("end", [])} for e in g.get("end", [])}
guides[g["id"]] = Guide(g["id"], g["title"], g["summary"], tuple(g.get("recommend", ())), guides[g["id"]] = Guide(g["id"], g["title"], g["summary"], tuple(g.get("recommend", ())),
g["start"], steps, ends) g["start"], steps, ends)
_validate(guides) _validate(guides)
return guides return guides
def _validate(guides: dict[str, Guide]) -> None: def _validate(guides: dict[str, Guide]) -> None:
for g in guides.values(): for g in guides.values():
if g.start not in g.steps: if g.start not in g.steps:
raise ValueError(f"guide {g.id}: start step {g.start!r} missing") raise ValueError(f"guide {g.id}: start step {g.start!r} missing")
for s in g.steps.values(): for s in g.steps.values():
if s.type not in STEP_TYPES: if s.type not in STEP_TYPES:
raise ValueError(f"{g.id}.{s.id}: unknown step type {s.type!r}") raise ValueError(f"{g.id}.{s.id}: unknown step type {s.type!r}")
if s.type == "led" and set(s.route) - set(LED_ANSWERS): if s.type == "led" and set(s.route) - set(LED_ANSWERS):
raise ValueError(f"{g.id}.{s.id}: unknown LED colours {set(s.route) - set(LED_ANSWERS)}") 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): 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") raise ValueError(f"{g.id}.{s.id}: wait step needs until/until_milestone/pass_on_timeout")
if s.tool_action not in (None, "reset"): if s.tool_action not in (None, "reset"):
raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}") raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}")
for t in s.targets(): for t in s.targets():
kind, _, ref = t.partition(":") kind, _, ref = t.partition(":")
if kind == "end" and ref not in g.ends: if kind == "end" and ref not in g.ends:
raise ValueError(f"{g.id}.{s.id}: unknown end {ref!r}") raise ValueError(f"{g.id}.{s.id}: unknown end {ref!r}")
elif kind == "guide" and ref not in guides: elif kind == "guide" and ref not in guides:
raise ValueError(f"{g.id}.{s.id}: unknown guide {ref!r}") raise ValueError(f"{g.id}.{s.id}: unknown guide {ref!r}")
elif not ref and t not in g.steps: elif not ref and t not in g.steps:
raise ValueError(f"{g.id}.{s.id}: unknown step {t!r}") raise ValueError(f"{g.id}.{s.id}: unknown step {t!r}")
for e in g.ends.values(): for e in g.ends.values():
if e.severity not in END_SEVERITY: if e.severity not in END_SEVERITY:
raise ValueError(f"{g.id}.end.{e.id}: unknown severity {e.severity!r}") raise ValueError(f"{g.id}.end.{e.id}: unknown severity {e.severity!r}")
def recommended(guides: dict[str, Guide], engine: DiagnosticsEngine) -> list[Guide]: def recommended(guides: dict[str, Guide], engine: DiagnosticsEngine) -> list[Guide]:
"""Guides matching the current root findings (in root order), then the LED.""" """Guides matching the current root findings (in root order), then the LED."""
report = engine.report() report = engine.report()
keys: list[str] = [] keys: list[str] = []
for rc in report.roots: for rc in report.roots:
keys.append(rc.finding.rule_id) keys.append(rc.finding.rule_id)
keys += [c.rule_id for c in rc.consequences] keys += [c.rule_id for c in rc.consequences]
if report.led not in ("UNKNOWN",): if report.led not in ("UNKNOWN",):
keys.append(f"led:{report.led}") keys.append(f"led:{report.led}")
out: list[Guide] = [g for g in guides.values() if "always" in g.recommend] out: list[Guide] = [g for g in guides.values() if "always" in g.recommend]
for k in keys: for k in keys:
for g in guides.values(): for g in guides.values():
if k in g.recommend and g not in out: if k in g.recommend and g not in out:
out.append(g) out.append(g)
return out return out
@dataclass @dataclass
class View: class View:
"""What the UI should show right now.""" """What the UI should show right now."""
guide: Guide | None = None guide: Guide | None = None
step: Step | None = None step: Step | None = None
text: str = "" text: str = ""
options: list[tuple[str, str]] = field(default_factory=list) # (key, label) options: list[tuple[str, str]] = field(default_factory=list) # (key, label)
waiting: bool = False waiting: bool = False
remaining_s: float = 0.0 remaining_s: float = 0.0
note: str = "" note: str = ""
end: End | None = None end: End | None = None
can_back: bool = False can_back: bool = False
class GuideRunner: class GuideRunner:
def __init__(self, engine: DiagnosticsEngine, guides: dict[str, Guide] | None = None, def __init__(self, engine: DiagnosticsEngine, guides: dict[str, Guide] | None = None,
on_action: Callable[[str], bool] | None = None, on_action: Callable[[str], bool] | None = None,
on_log: Callable[[str], None] | None = None, on_log: Callable[[str], None] | None = None,
clock: Callable[[], float] = time.monotonic): clock: Callable[[], float] = time.monotonic):
self.engine = engine self.engine = engine
self.guides = guides if guides is not None else load_guides() self.guides = guides if guides is not None else load_guides()
self._on_action = on_action or (lambda _a: False) self._on_action = on_action or (lambda _a: False)
self._on_log = on_log or (lambda _t: None) self._on_log = on_log or (lambda _t: None)
self._clock = clock self._clock = clock
self.cancel() self.cancel()
# ------------------------------------------------------------ control # ------------------------------------------------------------ control
def cancel(self) -> None: def cancel(self) -> None:
self.guide: Guide | None = None self.guide: Guide | None = None
self.step: Step | None = None self.step: Step | None = None
self.end: End | None = None self.end: End | None = None
self.note = "" self.note = ""
self._stack: list[tuple[str, str]] = [] # (guide id, step id) for Back self._stack: list[tuple[str, str]] = [] # (guide id, step id) for Back
self._wait_started = 0.0 self._wait_started = 0.0
self._wait_boot = 0 self._wait_boot = 0
self._wait_milestones: set[str] = set() self._wait_milestones: set[str] = set()
def start(self, guide_id: str) -> None: def start(self, guide_id: str) -> None:
self.cancel() self.cancel()
self._enter_guide(guide_id) self._enter_guide(guide_id)
def answer(self, key: str) -> None: def answer(self, key: str) -> None:
"""Pick an option: LED colour name for led steps, option index otherwise.""" """Pick an option: LED colour name for led steps, option index otherwise."""
s = self.step s = self.step
if s is None: if s is None:
return return
if s.type == "led": if s.type == "led":
label = LED_ANSWER_LABELS.get(key, key) label = LED_ANSWER_LABELS.get(key, key)
self._record(s, label) self._record(s, label)
self._check_led(key) self._check_led(key)
target = s.route.get(key) target = s.route.get(key)
elif s.type == "question": elif s.type == "question":
opt = s.options[int(key)] opt = s.options[int(key)]
self._record(s, opt.label) self._record(s, opt.label)
target = opt.next target = opt.next
else: else:
return return
if target: if target:
self._push() self._push()
self._go(target) self._go(target)
def proceed(self) -> None: def proceed(self) -> None:
"""Continue on info steps; 'skip waiting' on wait steps.""" """Continue on info steps; 'skip waiting' on wait steps."""
s = self.step s = self.step
if s is None: if s is None:
return return
if s.type == "info" and s.next: if s.type == "info" and s.next:
self._push() self._push()
self._go(s.next) self._go(s.next)
elif s.type == "wait": elif s.type == "wait":
self._record(s, "technician skipped the wait") self._record(s, "technician skipped the wait")
self._finish_wait("timeout") self._finish_wait("timeout")
def back(self) -> None: def back(self) -> None:
# Never step back into a wait: re-entering it could restart the box. # Never step back into a wait: re-entering it could restart the box.
while self._stack: while self._stack:
gid, sid = self._stack.pop() gid, sid = self._stack.pop()
if self.guides[gid].steps[sid].type != "wait": if self.guides[gid].steps[sid].type != "wait":
self.guide, self.end, self.note = self.guides[gid], None, "" self.guide, self.end, self.note = self.guides[gid], None, ""
self._enter_step(sid) self._enter_step(sid)
return return
# --------------------------------------------------------------- feed # --------------------------------------------------------------- feed
def feed(self, line: LogLine) -> None: def feed(self, line: LogLine) -> None:
s = self.step s = self.step
if s is None or s.type != "wait": if s is None or s.type != "wait":
return return
if s.fail_on and s.fail_on.search(line.text): if s.fail_on and s.fail_on.search(line.text):
self._record(s, f"log: {line.text}") self._record(s, f"log: {line.text}")
self._finish_wait("fail") self._finish_wait("fail")
elif s.until and s.until.search(line.text): elif s.until and s.until.search(line.text):
self._record(s, f"log: {line.text}") self._record(s, f"log: {line.text}")
self._finish_wait("ok") self._finish_wait("ok")
elif s.until_milestone and self._milestone_new(s.until_milestone): elif s.until_milestone and self._milestone_new(s.until_milestone):
self._record(s, f"milestone reached: {s.until_milestone}") self._record(s, f"milestone reached: {s.until_milestone}")
self._finish_wait("ok") self._finish_wait("ok")
def tick(self) -> None: def tick(self) -> None:
s = self.step s = self.step
if s is not None and s.type == "wait" and self._clock() - self._wait_started >= s.timeout_s: 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") self._finish_wait("ok" if s.pass_on_timeout else "timeout")
# --------------------------------------------------------------- view # --------------------------------------------------------------- view
def view(self) -> View: def view(self) -> View:
v = View(guide=self.guide, step=self.step, end=self.end, note=self.note, can_back=bool(self._stack)) v = View(guide=self.guide, step=self.step, end=self.end, note=self.note, can_back=bool(self._stack))
s = self.step s = self.step
if s is None: if s is None:
return v return v
v.text = self._render(s.text) v.text = self._render(s.text)
if s.type == "led": if s.type == "led":
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route] v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
elif s.type == "question": elif s.type == "question":
v.options = [(str(i), o.label) for i, o in enumerate(s.options)] v.options = [(str(i), o.label) for i, o in enumerate(s.options)]
elif s.type == "wait": elif s.type == "wait":
v.waiting = True v.waiting = True
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started)) v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
return v return v
# ---------------------------------------------------------- internals # ---------------------------------------------------------- internals
def _push(self) -> None: def _push(self) -> None:
if self.guide and self.step: if self.guide and self.step:
self._stack.append((self.guide.id, self.step.id)) self._stack.append((self.guide.id, self.step.id))
def _enter_guide(self, guide_id: str) -> None: def _enter_guide(self, guide_id: str) -> None:
self.guide = self.guides[guide_id] self.guide = self.guides[guide_id]
self._on_log(f"technician: started guide '{self.guide.title}'") self._on_log(f"technician: started guide '{self.guide.title}'")
self._enter_step(self.guide.start) self._enter_step(self.guide.start)
def _go(self, target: str) -> None: def _go(self, target: str) -> None:
kind, _, ref = target.partition(":") kind, _, ref = target.partition(":")
if kind == "end" and ref: if kind == "end" and ref:
self._conclude(self.guide.ends[ref]) self._conclude(self.guide.ends[ref])
elif kind == "guide" and ref: elif kind == "guide" and ref:
self._enter_guide(ref) self._enter_guide(ref)
else: else:
self._enter_step(target) self._enter_step(target)
def _enter_step(self, step_id: str) -> None: def _enter_step(self, step_id: str) -> None:
s = self.guide.steps[step_id] s = self.guide.steps[step_id]
self.step, self.note = s, "" self.step, self.note = s, ""
if s.skip_if_finding and self._finding_active(s.skip_if_finding): if s.skip_if_finding and self._finding_active(s.skip_if_finding):
self._auto(s, f"skipped - log already shows {s.skip_if_finding}", s.skip_to) self._auto(s, f"skipped - log already shows {s.skip_if_finding}", s.skip_to)
return return
if s.skip_unless_feature and not self._feature(s.skip_unless_feature): if s.skip_unless_feature and not self._feature(s.skip_unless_feature):
self._auto(s, f"skipped - firmware has no {s.skip_unless_feature}", s.skip_to) self._auto(s, f"skipped - firmware has no {s.skip_unless_feature}", s.skip_to)
return return
if s.type == "route": if s.type == "route":
target = self._route(s) target = self._route(s)
self._auto(s, f"routed by log findings -> {target}", target) self._auto(s, f"routed by log findings -> {target}", target)
return return
if s.type == "wait": if s.type == "wait":
self._wait_started = self._clock() self._wait_started = self._clock()
self._wait_boot = self.engine.boot.index self._wait_boot = self.engine.boot.index
self._wait_milestones = set(self.engine.milestones) self._wait_milestones = set(self.engine.milestones)
if s.tool_action and not self._on_action(s.tool_action): if s.tool_action and not self._on_action(s.tool_action):
self.note = "Could not restart the box from the tool - connect to it first, or switch it off and on." self.note = "Could not restart the box from the tool - connect to it first, or switch it off and on."
# A milestone that is already reached satisfies the wait at once # A milestone that is already reached satisfies the wait at once
# unless the step first restarts the box. # unless the step first restarts the box.
if s.until_milestone and not s.tool_action and s.until_milestone in self.engine.milestones: 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._record(s, f"already reached: {s.until_milestone}")
self._finish_wait("ok") self._finish_wait("ok")
def _auto(self, s: Step, why: str, target: str | None) -> None: def _auto(self, s: Step, why: str, target: str | None) -> None:
self._on_log(f"technician guide: {s.id}: {why}") self._on_log(f"technician guide: {s.id}: {why}")
if target: if target:
self._go(target) self._go(target)
def _finish_wait(self, outcome: str) -> None: def _finish_wait(self, outcome: str) -> None:
s = self.step s = self.step
target = {"ok": s.on_ok, "fail": s.on_fail or s.on_timeout, "timeout": s.on_timeout or s.on_fail}[outcome] target = {"ok": s.on_ok, "fail": s.on_fail or s.on_timeout, "timeout": s.on_timeout or s.on_fail}[outcome]
if target is None: if target is None:
return return
self._push() self._push()
self._go(target) self._go(target)
def _milestone_new(self, mid: str) -> bool: def _milestone_new(self, mid: str) -> bool:
if mid not in self.engine.milestones: if mid not in self.engine.milestones:
return False return False
return self.engine.boot.index != self._wait_boot or mid not in self._wait_milestones return self.engine.boot.index != self._wait_boot or mid not in self._wait_milestones
def _route(self, s: Step) -> str: def _route(self, s: Step) -> str:
report = self.engine.report() report = self.engine.report()
for rc in report.roots: for rc in report.roots:
for f in (rc.finding, *rc.consequences): for f in (rc.finding, *rc.consequences):
if f.rule_id in s.routes: if f.rule_id in s.routes:
return s.routes[f.rule_id] return s.routes[f.rule_id]
return s.default or "end:" + next(iter(self.guide.ends)) return s.default or "end:" + next(iter(self.guide.ends))
def _check_led(self, seen: str) -> None: def _check_led(self, seen: str) -> None:
expected = self.engine.led expected = self.engine.led
if expected in ("UNKNOWN", "OFF") or seen == "CYCLING" or seen == expected: if expected in ("UNKNOWN", "OFF") or seen == "CYCLING" or seen == expected:
return return
exp_label, see_label = LED[expected][0], LED_ANSWER_LABELS[seen] exp_label, see_label = LED[expected][0], LED_ANSWER_LABELS[seen]
self.engine.add_confirmed( self.engine.add_confirmed(
"tech.led_mismatch", Severity.WARNING, "tech.led_mismatch", Severity.WARNING,
f"LED shows {see_label}, but the log implies {exp_label}", f"LED shows {see_label}, but the log implies {exp_label}",
f"The firmware state in the log corresponds to {exp_label}, while the technician sees {see_label}. " f"The firmware state in the log corresponds to {exp_label}, while the technician sees {see_label}. "
"Either the LED just changed (check again in a few seconds), the LED/its wiring is faulty, " "Either the LED just changed (check again in a few seconds), the LED/its wiring is faulty, "
"or the box runs different firmware. Trust the log findings.", "or the box runs different firmware. Trust the log findings.",
("Look at the LED again for 10 seconds.", "If it stays different: note it for repair (LED board).")) ("Look at the LED again for 10 seconds.", "If it stays different: note it for repair (LED board)."))
def _conclude(self, end: End) -> None: def _conclude(self, end: End) -> None:
self.step, self.end = None, end self.step, self.end = None, end
self._on_log(f"technician guide result: {end.title}") self._on_log(f"technician guide result: {end.title}")
sev = END_SEVERITY[end.severity] sev = END_SEVERITY[end.severity]
self.engine.note_technician(f"{self.guide.title}: {end.title}") self.engine.note_technician(f"{self.guide.title}: {end.title}")
if sev is not None: if sev is not None:
self.engine.add_confirmed(f"tech.{self.guide.id}.{end.id}", sev, end.title, end.text, end.actions) self.engine.add_confirmed(f"tech.{self.guide.id}.{end.id}", sev, end.title, end.text, end.actions)
def _record(self, s: Step, answer: str) -> None: def _record(self, s: Step, answer: str) -> None:
q = self._render(s.text) q = self._render(s.text)
self.engine.note_technician(f"{q} -> {answer}") self.engine.note_technician(f"{q} -> {answer}")
self._on_log(f"technician: {q[:80]} -> {answer}") self._on_log(f"technician: {q[:80]} -> {answer}")
def _finding_active(self, rule_id: str) -> bool: def _finding_active(self, rule_id: str) -> bool:
return any(f.rule_id == rule_id and f.active for f in self.engine.findings.values()) return any(f.rule_id == rule_id and f.active for f in self.engine.findings.values())
def _feature(self, name: str) -> bool: def _feature(self, name: str) -> bool:
fw = self.engine.firmware fw = self.engine.firmware
if name == "sd_wifi": if name == "sd_wifi":
return fw.supports_sd_wifi is not False return fw.supports_sd_wifi is not False
if name == "setup_portal": if name == "setup_portal":
if fw.known is not None: if fw.known is not None:
return fw.known.setup_portal return fw.known.setup_portal
if "setup_portal" in fw.evidence: if "setup_portal" in fw.evidence:
return True return True
return "legacy_v1" not in fw.evidence # unknown firmware: ask return "legacy_v1" not in fw.evidence # unknown firmware: ask
return False return False
def _render(self, text: str) -> str: def _render(self, text: str) -> str:
report = self.engine.report() report = self.engine.report()
sensors = sorted({f.title.removesuffix(" failed to start") for f in self.engine.findings.values() sensors = sorted({f.title.removesuffix(" failed to start") for f in self.engine.findings.values()
if f.rule_id == "sensor.init_failed" and f.active}) if f.rule_id == "sensor.init_failed" and f.active})
ctx = { ctx = {
"expected_led": LED[report.led][0], "expected_led": LED[report.led][0],
"failing_sensors": ", ".join(sensors) or "see the Diagnosis tab", "failing_sensors": ", ".join(sensors) or "see the Diagnosis tab",
"top_finding": report.roots[0].finding.title if report.roots else "none", "top_finding": report.roots[0].finding.title if report.roots else "none",
} }
return text.format_map(_Fmt(ctx)) return text.format_map(_Fmt(ctx))
class _Fmt(dict): class _Fmt(dict):
def __missing__(self, key: str) -> str: def __missing__(self, key: str) -> str:
return "{" + key + "}" return "{" + key + "}"
+740 -740
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -39,6 +39,7 @@ I2C_DEVICES = {"0x39": "AS7341", "0x68": "DS1307", "0x6B": "SEN66", "0x76": "BME
INTENTIONAL_RESTARTS = { INTENTIONAL_RESTARTS = {
"ota_update", "button_provisioning_abort", "button_wifi_cleared", "ota_update", "button_provisioning_abort", "button_wifi_cleared",
"button_provisioning_toggle", "provisioning_saved", "button_provisioning_toggle", "provisioning_saved",
"usb_wifi_set", "usb_wifi_clear", # Wi-Fi changed from the technician tool (usb_console)
} }
MODULES = { MODULES = {
+732 -724
View File
File diff suppressed because it is too large. Load diff
+260 -260
View File
@@ -1,260 +1,260 @@
"""Firmware images: validation, integrity, layout and the firmware folder. """Firmware images: validation, integrity, layout and the firmware folder.
The tool flashes ONLY the .bin files in its own firmware/ folder (next to 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, 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 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 compile date stored in the firmware itself (ESP-IDF app descriptor), newest
first, so the file name does not matter. first, so the file name does not matter.
Two kinds: Two kinds:
* merged - bootloader at 0x0 + partition table + app (merged-binary.bin); * merged - bootloader at 0x0 + partition table + app (merged-binary.bin);
App-only or Full flashing; App-only or Full flashing;
* app - the application binary only (build/kota-sensorboard.bin); * app - the application binary only (build/kota-sensorboard.bin);
App-only flashing, laid out like a merged image in the folder. App-only flashing, laid out like a merged image in the folder.
Every image is checked structurally (ESP32-S3, kota-sensorboard app Every image is checked structurally (ESP32-S3, kota-sensorboard app
descriptor, segment checksum and the appended SHA-256) so a truncated or descriptor, segment checksum and the appended SHA-256) so a truncated or
corrupted file is refused before anything is written. corrupted file is refused before anything is written.
""" """
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
import hashlib import hashlib
import struct import struct
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware" FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware"
PT_OFFSET = 0x8000 PT_OFFSET = 0x8000
PT_SIZE = 0xC00 PT_SIZE = 0xC00
ESP32S3_CHIP_ID = 9 ESP32S3_CHIP_ID = 9
IMAGE_MAGIC = 0xE9 IMAGE_MAGIC = 0xE9
APP_DESC_MAGIC = 0xABCD5432 APP_DESC_MAGIC = 0xABCD5432
FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"} FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"}
_MONTHS = {m: i for i, m in enumerate( _MONTHS = {m: i for i, m in enumerate(
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 1)} ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 1)}
class ImageError(ValueError): class ImageError(ValueError):
pass pass
@dataclass(frozen=True) @dataclass(frozen=True)
class Partition: class Partition:
label: str label: str
type: int type: int
subtype: int subtype: int
offset: int offset: int
size: int size: int
@dataclass(frozen=True) @dataclass(frozen=True)
class AppInfo: class AppInfo:
project: str project: str
version: str version: str
built: str # "Sep 28 2026 15:07:57" as in the app descriptor built: str # "Sep 28 2026 15:07:57" as in the app descriptor
idf: str idf: str
elf_sha: str # hex elf_sha: str # hex
@property @property
def built_at(self) -> dt.datetime | None: def built_at(self) -> dt.datetime | None:
try: try:
mon, day, year, hms = self.built.split() mon, day, year, hms = self.built.split()
h, m, s = (int(x) for x in hms.split(":")) h, m, s = (int(x) for x in hms.split(":"))
return dt.datetime(int(year), _MONTHS[mon], int(day), h, m, s) return dt.datetime(int(year), _MONTHS[mon], int(day), h, m, s)
except (ValueError, KeyError): except (ValueError, KeyError):
return None return None
@property @property
def compiled(self) -> str: def compiled(self) -> str:
"""'2026-09-28 15:07' (ISO-like, sortable) or the raw descriptor text.""" """'2026-09-28 15:07' (ISO-like, sortable) or the raw descriptor text."""
t = self.built_at t = self.built_at
return t.strftime("%Y-%m-%d %H:%M") if t else self.built return t.strftime("%Y-%m-%d %H:%M") if t else self.built
@dataclass(frozen=True) @dataclass(frozen=True)
class FirmwareImage: class FirmwareImage:
path: Path path: Path
kind: str # "merged" | "app" kind: str # "merged" | "app"
sha256: str sha256: str
flash_size: str flash_size: str
partitions: tuple[Partition, ...] partitions: tuple[Partition, ...]
partition_table: bytes # expected table on the box partition_table: bytes # expected table on the box
app_offset: int # where the app starts inside the file app_offset: int # where the app starts inside the file
app_length: int # exact app image length (from its structure) app_length: int # exact app image length (from its structure)
app: AppInfo app: AppInfo
@property @property
def name(self) -> str: def name(self) -> str:
return self.path.name return self.path.name
@property @property
def data(self) -> bytes: def data(self) -> bytes:
return self.path.read_bytes() return self.path.read_bytes()
def partition(self, label: str) -> Partition: def partition(self, label: str) -> Partition:
for p in self.partitions: for p in self.partitions:
if p.label == label: if p.label == label:
return p return p
raise ImageError(f"partition {label!r} not in image") raise ImageError(f"partition {label!r} not in image")
def app_bytes(self) -> bytes: def app_bytes(self) -> bytes:
return self.data[self.app_offset:self.app_offset + self.app_length] return self.data[self.app_offset:self.app_offset + self.app_length]
def blank_otadata(self) -> bytes: def blank_otadata(self) -> bytes:
"""Erased otadata: the bootloader then boots ota_0.""" """Erased otadata: the bootloader then boots ota_0."""
return b"\xff" * self.partition("otadata").size return b"\xff" * self.partition("otadata").size
def label(self) -> str: def label(self) -> str:
"""Choice text: compile date first (that is what the technician picks by).""" """Choice text: compile date first (that is what the technician picks by)."""
kind = "" if self.kind == "merged" else ", app only" 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})" return f"Compiled {self.app.compiled} - v{self.app.version} [{self.app.elf_sha[:8]}]{kind} ({self.name})"
# ---------------------------------------------------------------- parsing # ---------------------------------------------------------------- parsing
def parse_partition_table(raw: bytes) -> tuple[Partition, ...]: def parse_partition_table(raw: bytes) -> tuple[Partition, ...]:
parts = [] parts = []
for i in range(0, len(raw) - 31, 32): for i in range(0, len(raw) - 31, 32):
entry = raw[i:i + 32] entry = raw[i:i + 32]
magic = entry[:2] magic = entry[:2]
if magic == b"\xaa\x50": if magic == b"\xaa\x50":
_, ptype, sub, off, size = struct.unpack_from("<HBBII", entry) _, ptype, sub, off, size = struct.unpack_from("<HBBII", entry)
label = entry[12:28].split(b"\0")[0].decode("ascii", "replace") label = entry[12:28].split(b"\0")[0].decode("ascii", "replace")
parts.append(Partition(label, ptype, sub, off, size)) parts.append(Partition(label, ptype, sub, off, size))
elif magic in (b"\xeb\xeb", b"\xff\xff"): # MD5 entry / end elif magic in (b"\xeb\xeb", b"\xff\xff"): # MD5 entry / end
break break
else: else:
raise ImageError(f"corrupt partition table entry at 0x{PT_OFFSET + i:x}") raise ImageError(f"corrupt partition table entry at 0x{PT_OFFSET + i:x}")
return tuple(parts) return tuple(parts)
def app_image_length(data: bytes, offset: int = 0) -> int: def app_image_length(data: bytes, offset: int = 0) -> int:
"""Validate an ESP app image at `offset`; return its exact length. """Validate an ESP app image at `offset`; return its exact length.
Checks the segment checksum and, when the header says so, the appended Checks the segment checksum and, when the header says so, the appended
SHA-256 - the same checks the bootloader performs before booting it. SHA-256 - the same checks the bootloader performs before booting it.
""" """
if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC: if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC:
raise ImageError(f"no firmware image at 0x{offset:x}") raise ImageError(f"no firmware image at 0x{offset:x}")
segments = data[offset + 1] segments = data[offset + 1]
chip_id = struct.unpack_from("<H", data, offset + 12)[0] chip_id = struct.unpack_from("<H", data, offset + 12)[0]
if chip_id != ESP32S3_CHIP_ID: if chip_id != ESP32S3_CHIP_ID:
raise ImageError(f"image is for chip id {chip_id}, not ESP32-S3") raise ImageError(f"image is for chip id {chip_id}, not ESP32-S3")
hash_appended = data[offset + 23] == 1 hash_appended = data[offset + 23] == 1
pos, checksum = offset + 24, 0xEF pos, checksum = offset + 24, 0xEF
for _ in range(segments): for _ in range(segments):
if pos + 8 > len(data): if pos + 8 > len(data):
raise ImageError("image is truncated (segment header missing)") raise ImageError("image is truncated (segment header missing)")
_, seg_len = struct.unpack_from("<II", data, pos) _, seg_len = struct.unpack_from("<II", data, pos)
pos += 8 pos += 8
if pos + seg_len > len(data): if pos + seg_len > len(data):
raise ImageError("image is truncated (segment data missing)") raise ImageError("image is truncated (segment data missing)")
for b in data[pos:pos + seg_len]: for b in data[pos:pos + seg_len]:
checksum ^= b checksum ^= b
pos += seg_len pos += seg_len
pos += 15 - ((pos - offset) % 16) # pad so the checksum ends a 16-byte block pos += 15 - ((pos - offset) % 16) # pad so the checksum ends a 16-byte block
if pos >= len(data) or data[pos] != checksum: if pos >= len(data) or data[pos] != checksum:
raise ImageError("image checksum is wrong - the file is corrupted") raise ImageError("image checksum is wrong - the file is corrupted")
pos += 1 pos += 1
if hash_appended: if hash_appended:
if pos + 32 > len(data): if pos + 32 > len(data):
raise ImageError("image is truncated (SHA-256 missing)") raise ImageError("image is truncated (SHA-256 missing)")
if hashlib.sha256(data[offset:pos]).digest() != data[pos:pos + 32]: if hashlib.sha256(data[offset:pos]).digest() != data[pos:pos + 32]:
raise ImageError("image SHA-256 does not match its content - the file is corrupted") raise ImageError("image SHA-256 does not match its content - the file is corrupted")
pos += 32 pos += 32
return pos - offset return pos - offset
def _app_info(data: bytes, offset: int) -> AppInfo: def _app_info(data: bytes, offset: int) -> AppInfo:
d = data[offset + 0x20:offset + 0x20 + 256] d = data[offset + 0x20:offset + 0x20 + 256]
if len(d) < 256 or struct.unpack_from("<I", d)[0] != APP_DESC_MAGIC: if len(d) < 256 or struct.unpack_from("<I", d)[0] != APP_DESC_MAGIC:
raise ImageError("firmware has no ESP-IDF app descriptor") raise ImageError("firmware has no ESP-IDF app descriptor")
s = lambda o, n: d[o:o + n].split(b"\0")[0].decode("ascii", "replace") s = lambda o, n: d[o:o + n].split(b"\0")[0].decode("ascii", "replace")
info = AppInfo(project=s(48, 32), version=s(16, 32), built=f"{s(96, 16)} {s(80, 16)}", info = AppInfo(project=s(48, 32), version=s(16, 32), built=f"{s(96, 16)} {s(80, 16)}",
idf=s(112, 32), elf_sha=d[144:176].hex()) idf=s(112, 32), elf_sha=d[144:176].hex())
if info.project != "kota-sensorboard": if info.project != "kota-sensorboard":
raise ImageError(f"firmware is {info.project!r}, not kota-sensorboard") raise ImageError(f"firmware is {info.project!r}, not kota-sensorboard")
return info return info
def inspect_image(path: Path, reference: FirmwareImage | None = None) -> FirmwareImage: def inspect_image(path: Path, reference: FirmwareImage | None = None) -> FirmwareImage:
"""Validate a merged or app-only image. App-only images need a `reference` """Validate a merged or app-only image. App-only images need a `reference`
(a merged image) that provides the flash layout.""" (a merged image) that provides the flash layout."""
data = path.read_bytes() 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": 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 app_image_length(data, 0) # bootloader must be intact
table = data[PT_OFFSET:PT_OFFSET + PT_SIZE] table = data[PT_OFFSET:PT_OFFSET + PT_SIZE]
parts = parse_partition_table(table) parts = parse_partition_table(table)
labels = {p.label for p in parts} labels = {p.label for p in parts}
for need in ("nvs", "otadata", "ota_0"): for need in ("nvs", "otadata", "ota_0"):
if need not in labels: if need not in labels:
raise ImageError(f"partition table has no {need!r} partition") raise ImageError(f"partition table has no {need!r} partition")
ota0 = next(p for p in parts if p.label == "ota_0") ota0 = next(p for p in parts if p.label == "ota_0")
kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?") kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?")
elif data[:1] == bytes([IMAGE_MAGIC]): elif data[:1] == bytes([IMAGE_MAGIC]):
if reference is None: if reference is None:
raise ImageError("app-only file, but there is no complete (merged) image in the folder " raise ImageError("app-only file, but there is no complete (merged) image in the folder "
"to take the flash layout from") "to take the flash layout from")
kind, app_off = "app", 0 kind, app_off = "app", 0
parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size
ota0 = next(p for p in parts if p.label == "ota_0") ota0 = next(p for p in parts if p.label == "ota_0")
else: else:
raise ImageError("not an ESP32 firmware file") raise ImageError("not an ESP32 firmware file")
length = app_image_length(data, app_off) length = app_image_length(data, app_off)
if length > ota0.size: if length > ota0.size:
raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)") raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)")
app = _app_info(data, app_off) app = _app_info(data, app_off)
return FirmwareImage(path, kind, hashlib.sha256(data).hexdigest(), return FirmwareImage(path, kind, hashlib.sha256(data).hexdigest(),
flash_size, parts, table, app_off, length, app) flash_size, parts, table, app_off, length, app)
# ------------------------------------------------------------- catalogue # ------------------------------------------------------------- catalogue
def folder_images(folder: Path = FIRMWARE_DIR) -> tuple[list[FirmwareImage], list[str]]: def folder_images(folder: Path = FIRMWARE_DIR) -> tuple[list[FirmwareImage], list[str]]:
"""(valid images newest-compiled first, problems) for the .bin files in `folder`. """(valid images newest-compiled first, problems) for the .bin files in `folder`.
A broken or foreign file is reported, never offered. The same build A broken or foreign file is reported, never offered. The same build
under two file names is offered once. under two file names is offered once.
""" """
if not folder.is_dir(): if not folder.is_dir():
return [], [f"firmware folder not found: {folder}"] return [], [f"firmware folder not found: {folder}"]
files = sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == ".bin") files = sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == ".bin")
merged: list[FirmwareImage] = [] merged: list[FirmwareImage] = []
app_only: list[Path] = [] app_only: list[Path] = []
problems: list[str] = [] problems: list[str] = []
for f in files: for f in files:
try: try:
merged.append(inspect_image(f)) merged.append(inspect_image(f))
except ImageError as exc: except ImageError as exc:
if "app-only" in str(exc): if "app-only" in str(exc):
app_only.append(f) app_only.append(f)
else: else:
problems.append(f"{f.name}: {exc}") problems.append(f"{f.name}: {exc}")
except OSError as exc: except OSError as exc:
problems.append(f"{f.name}: cannot read ({exc})") problems.append(f"{f.name}: cannot read ({exc})")
merged.sort(key=_newest_first) merged.sort(key=_newest_first)
reference = merged[0] if merged else None reference = merged[0] if merged else None
images = list(merged) images = list(merged)
for f in app_only: for f in app_only:
try: try:
images.append(inspect_image(f, reference)) images.append(inspect_image(f, reference))
except (ImageError, OSError) as exc: except (ImageError, OSError) as exc:
problems.append(f"{f.name}: {exc}") problems.append(f"{f.name}: {exc}")
images.sort(key=_newest_first) images.sort(key=_newest_first)
unique: dict[str, FirmwareImage] = {} unique: dict[str, FirmwareImage] = {}
for img in images: for img in images:
if img.sha256 in unique: if img.sha256 in unique:
problems.append(f"{img.name}: same file as {unique[img.sha256].name} (listed once)") problems.append(f"{img.name}: same file as {unique[img.sha256].name} (listed once)")
else: else:
unique[img.sha256] = img unique[img.sha256] = img
return list(unique.values()), problems return list(unique.values()), problems
def _newest_first(img: FirmwareImage) -> tuple: def _newest_first(img: FirmwareImage) -> tuple:
# Unknown compile dates last; merged before app-only for the same date. # Unknown compile dates last; merged before app-only for the same date.
t = img.app.built_at t = img.app.built_at
return (t is None, -t.timestamp() if t else 0.0, img.kind != "merged", img.name) return (t is None, -t.timestamp() if t else 0.0, img.kind != "merged", img.name)
+1 -1
View File
@@ -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, " "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."), "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), (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), (re.compile(r"Invalid head of packet|timed out", re.I),
"Communication errors. Try another USB cable or port."), "Communication errors. Try another USB cable or port."),
] ]
+113 -113
View File
@@ -1,113 +1,113 @@
"""Matter onboarding payloads: the "MT:..." QR string and the 11-digit manual code. """Matter onboarding payloads: the "MT:..." QR string and the 11-digit manual code.
Implements the Matter spec (5.1.3 QR payload, 5.1.4 manual pairing code): Implements the Matter spec (5.1.3 QR payload, 5.1.4 manual pairing code):
bit fields packed LSB-first, Base38 in 3-byte chunks, Verhoeff check digit. bit fields packed LSB-first, Base38 in 3-byte chunks, Verhoeff check digit.
The sensorbox firmware uses the Matter SDK example commissionable data The sensorbox firmware uses the Matter SDK example commissionable data
(CONFIG_EXAMPLE_COMMISSIONABLE_DATA_PROVIDER): every v1.0 box advertises (CONFIG_EXAMPLE_COMMISSIONABLE_DATA_PROVIDER): every v1.0 box advertises
VID 0xFFF1 / PID 0x8000 / discriminator 3840 with passcode 20202021, which is VID 0xFFF1 / PID 0x8000 / discriminator 3840 with passcode 20202021, which is
exactly the sticker (QR "MT:Y.K9042C00KA0648G00", manual 3497-011-2332). exactly the sticker (QR "MT:Y.K9042C00KA0648G00", manual 3497-011-2332).
VID/PID/discriminator are taken from the box's own log when available; the VID/PID/discriminator are taken from the box's own log when available; the
passcode is never logged, so the known default is used. passcode is never logged, so the known default is used.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
BASE38 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-." BASE38 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-."
RENDEZVOUS_BLE = 1 << 1 RENDEZVOUS_BLE = 1 << 1
DEFAULT_PASSCODE = 20202021 # Matter SDK test passcode used by the v1.0 firmware DEFAULT_PASSCODE = 20202021 # Matter SDK test passcode used by the v1.0 firmware
DEFAULT = dict(vendor_id=0xFFF1, product_id=0x8000, discriminator=3840) DEFAULT = dict(vendor_id=0xFFF1, product_id=0x8000, discriminator=3840)
# chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0 # chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
_ADVERTISE = re.compile(r"vendorID=(\d+) productID=(\d+) discriminator=(\d+)") _ADVERTISE = re.compile(r"vendorID=(\d+) productID=(\d+) discriminator=(\d+)")
@dataclass(frozen=True) @dataclass(frozen=True)
class SetupPayload: class SetupPayload:
vendor_id: int = DEFAULT["vendor_id"] vendor_id: int = DEFAULT["vendor_id"]
product_id: int = DEFAULT["product_id"] product_id: int = DEFAULT["product_id"]
discriminator: int = DEFAULT["discriminator"] discriminator: int = DEFAULT["discriminator"]
passcode: int = DEFAULT_PASSCODE passcode: int = DEFAULT_PASSCODE
rendezvous: int = RENDEZVOUS_BLE rendezvous: int = RENDEZVOUS_BLE
flow: int = 0 # standard commissioning flow flow: int = 0 # standard commissioning flow
version: int = 0 version: int = 0
from_log: bool = False # VID/PID/discriminator read from this box's log 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:...") 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 manual: str | None = None # exact 11-digit manual code reported by the box
@property @property
def from_box(self) -> bool: def from_box(self) -> bool:
return self.qr is not None return self.qr is not None
def qr_text(self) -> str: def qr_text(self) -> str:
if self.qr: if self.qr:
return self.qr return self.qr
fields = [(self.version, 3), (self.vendor_id, 16), (self.product_id, 16), (self.flow, 2), 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)] (self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)]
value, shift = 0, 0 value, shift = 0, 0
for v, bits in fields: for v, bits in fields:
if not 0 <= v < (1 << bits): if not 0 <= v < (1 << bits):
raise ValueError(f"field value {v} does not fit in {bits} bits") raise ValueError(f"field value {v} does not fit in {bits} bits")
value |= v << shift value |= v << shift
shift += bits shift += bits
return "MT:" + _base38(value.to_bytes(shift // 8, "little")) return "MT:" + _base38(value.to_bytes(shift // 8, "little"))
def manual_code(self) -> str: def manual_code(self) -> str:
if self.manual: if self.manual:
return self.manual return self.manual
short = self.discriminator >> 8 # upper 4 bits short = self.discriminator >> 8 # upper 4 bits
chunk1 = short >> 2 # vid_pid_present = 0 chunk1 = short >> 2 # vid_pid_present = 0
chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF) chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF)
chunk3 = self.passcode >> 14 chunk3 = self.passcode >> 14
digits = f"{chunk1}{chunk2:05d}{chunk3:04d}" digits = f"{chunk1}{chunk2:05d}{chunk3:04d}"
return digits + str(verhoeff_check(digits)) return digits + str(verhoeff_check(digits))
def manual_code_pretty(self) -> str: def manual_code_pretty(self) -> str:
c = self.manual_code() c = self.manual_code()
return f"{c[:4]}-{c[4:7]}-{c[7:]}" return f"{c[:4]}-{c[4:7]}-{c[7:]}"
def _base38(data: bytes) -> str: def _base38(data: bytes) -> str:
out = [] out = []
for i in range(0, len(data), 3): for i in range(0, len(data), 3):
chunk = data[i:i + 3] chunk = data[i:i + 3]
value = int.from_bytes(chunk, "little") value = int.from_bytes(chunk, "little")
for _ in range({3: 5, 2: 4, 1: 2}[len(chunk)]): for _ in range({3: 5, 2: 4, 1: 2}[len(chunk)]):
value, rem = divmod(value, 38) value, rem = divmod(value, 38)
out.append(BASE38[rem]) out.append(BASE38[rem])
return "".join(out) return "".join(out)
_D = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], _D = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1], [3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]] [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]]
_P = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], _P = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1], [8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8]] [2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8]]
_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9] _INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]
def verhoeff_check(digits: str) -> int: def verhoeff_check(digits: str) -> int:
c = 0 c = 0
for i, d in enumerate(reversed(digits)): for i, d in enumerate(reversed(digits)):
c = _D[c][_P[(i + 1) % 8][int(d)]] c = _D[c][_P[(i + 1) % 8][int(d)]]
return _INV[c] return _INV[c]
def payload_from_log(lines: list[str], qr: str | None = None, manual: str | None = None) -> SetupPayload: 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 """Firmware 1.0.16+ reports its exact QR payload and manual code
("KOTA: matter ... qr=MT:... manual=..."); older firmware: use the ("KOTA: matter ... qr=MT:... manual=..."); older firmware: use the
advertised VID/PID/discriminator if seen; defaults otherwise.""" advertised VID/PID/discriminator if seen; defaults otherwise."""
base = SetupPayload() base = SetupPayload()
for text in reversed(lines): for text in reversed(lines):
if m := _ADVERTISE.search(text): if m := _ADVERTISE.search(text):
base = SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True) base = SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True)
break break
if qr: if qr:
return SetupPayload(base.vendor_id, base.product_id, base.discriminator, from_log=True, return SetupPayload(base.vendor_id, base.product_id, base.discriminator, from_log=True,
qr=qr, manual=manual if manual and manual.isdigit() else None) qr=qr, manual=manual if manual and manual.isdigit() else None)
return base return base
+3 -2
View File
@@ -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).""" """What the box would find on this card (values masked in the UI)."""
root = Path(mount) root = Path(mount)
problems: list[str] = [] problems: list[str] = []
fs = filesystem.upper() # Windows says "FAT32"; macOS diskutil says "DOS_FAT_32" / "Windows_FAT_32" / "MS-DOS FAT32".
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"): 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.") problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
env_file = find_case_insensitive(root, ENV_NAME) env_file = find_case_insensitive(root, ENV_NAME)
env = parse_env(env_file.read_bytes()) if env_file else None env = parse_env(env_file.read_bytes()) if env_file else None
+283
View File
@@ -0,0 +1,283 @@
"""Commands to the box over the USB serial port (firmware with usb_console, api=1).
Protocol (kota-sensorboard main/usb_console.hpp): ASCII lines "kota ...",
one reply line each, logged as "KOTA: cmd <name> ok=1|0 [key=value ...]":
kota ping -> cmd ping ok=1 fw=<version> api=1
kota health -> cmd health ok=1 <health fields> | ok=0 err=not_ready
kota wifi get -> cmd wifi_get ok=1 ssid=<hex|-> password_set=0|1 mode=...
kota wifi set <ssid_hex> <pass_hex> -> cmd wifi_set ok=1 restart=1, then restart (usb_wifi_set)
kota wifi clear -> cmd wifi_clear ok=1 restart=1, then restart (usb_wifi_clear)
Firmware without the console never answers. The password is only ever sent
hex-encoded and is never written to the log by the box or by this tool.
WifiFlow runs a whole "write / clear Wi-Fi" job as a small state machine
driven by `feed(line)` and `tick(now)`, so it is testable without Qt or a box.
"""
from __future__ import annotations
import enum
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from ..core.logline import LineKind, LogLine
API_VERSION = 1
# Technician-facing text for the firmware's err= codes.
ERRORS = {
"bad_args": "The box did not understand the command (tool/firmware mismatch).",
"bad_hex": "The box could not decode the settings (tool/firmware mismatch).",
"bad_char": "The name or password contains a line break.",
"ssid_length": "The network name must be 1-32 bytes.",
"password_length": "The password must be 8-63 characters.",
"sd_not_mounted": "The box cannot read its SD card, so it cannot store the settings.",
"write_failed": "Writing ENV.TXT on the SD card failed.",
"verify_failed": "ENV.TXT did not read back correctly after writing.",
"unknown_command": "This firmware does not know the command.",
"not_ready": "The box is still starting; try again in a few seconds.",
}
def hex_utf8(text: str) -> str:
return text.encode("utf-8").hex()
def unhex_utf8(value: str) -> str | None:
if not value or value == "-":
return None
try:
return bytes.fromhex(value).decode("utf-8", errors="replace")
except ValueError:
return None
def validate_wifi(ssid: str, password: str) -> list[str]:
"""Same limits as the firmware (usb_console.cpp); empty list = OK."""
problems = []
if not ssid:
problems.append("Enter the Wi-Fi network name (SSID).")
elif len(ssid.encode("utf-8")) > 32:
problems.append("The network name is longer than 32 bytes.")
if not 8 <= len(password.encode("utf-8")) <= 63:
problems.append("The password must be 8-63 characters (WPA2). Open networks are not supported.")
if any(c in ssid + password for c in "\r\n\0"):
problems.append("The name or password contains a line break.")
return problems
def wifi_set_command(ssid: str, password: str) -> str:
problems = validate_wifi(ssid, password)
if problems:
raise ValueError(" ".join(problems))
return f"kota wifi set {hex_utf8(ssid)} {hex_utf8(password)}"
def loggable(command: str) -> str:
"""The command as it may appear in the session log: never the password."""
parts = command.split()
if parts[:3] == ["kota", "wifi", "set"] and len(parts) == 5:
return f"kota wifi set (network '{unhex_utf8(parts[3])}', password hidden)"
return command
@dataclass(frozen=True)
class Reply:
name: str # ping | health | wifi_get | wifi_set | wifi_clear | unknown
ok: bool
fields: dict[str, str]
@property
def error(self) -> str:
code = self.fields.get("err", "")
return ERRORS.get(code, f"The box reported an error ({code or 'unknown'}).")
def parse_reply(line: LogLine) -> Reply | None:
"""'I (5) KOTA: cmd wifi_set ok=1 restart=1' -> Reply('wifi_set', True, {...})."""
if line.kind != LineKind.KOTA or line.record != "cmd" or not line.words:
return None
return Reply(line.words[0], line.fields.get("ok") == "1", dict(line.fields))
@dataclass(frozen=True)
class WifiInfo:
"""What `kota wifi get` reported."""
ssid: str | None
password_set: bool
mode: str
@classmethod
def from_reply(cls, r: Reply) -> WifiInfo:
return cls(unhex_utf8(r.fields.get("ssid", "-")), r.fields.get("password_set") == "1",
r.fields.get("mode", "?"))
def describe(self) -> str:
mode = {"wifi": "local Wi-Fi from the SD card", "matter": "Matter (Wi-Fi given by the Matter app)",
"provisioning": "Wi-Fi setup portal"}.get(self.mode, self.mode)
net = f"network '{self.ssid}'" + (", password set" if self.password_set else ", NO password")
return f"Mode: {mode}. " + (net if self.ssid else "No network stored on the SD card.")
# ------------------------------------------------------------------ flows
class Step(str, enum.Enum):
PING = "Checking the box accepts USB commands"
READ = "Reading the Wi-Fi settings"
SEND = "Storing the settings on the box's SD card"
RESTART = "Waiting for the box to restart"
CONNECT = "Waiting for the box to join the Wi-Fi network"
DONE = "Done"
FAILED = "Failed"
@dataclass
class FlowEvent:
step: Step
text: str
ok: bool | None = None # None = in progress, True = finished OK, False = failed
PING_TIMEOUT_S = 3.0
REPLY_TIMEOUT_S = 8.0
RESTART_TIMEOUT_S = 40.0
CONNECT_TIMEOUT_S = 90.0
HEALTH_POLL_S = 5.0
@dataclass
class WifiFlow:
"""One technician job: "read", "set" (ssid+password) or "clear".
`send(text)` writes one command line to the box (returns False when the
port is not connected); `on_event` reports progress for the UI.
"""
action: str
send: Callable[[str], bool]
on_event: Callable[[FlowEvent], None]
ssid: str = ""
password: str = ""
now: Callable[[], float] = time.monotonic
step: Step = Step.PING
info: WifiInfo | None = None
_deadline: float = 0.0
_next_poll: float = 0.0
_events: list[FlowEvent] = field(default_factory=list)
@property
def finished(self) -> bool:
return self.step in (Step.DONE, Step.FAILED)
def start(self) -> None:
if self.action == "set":
problems = validate_wifi(self.ssid, self.password)
if problems:
return self._fail(" ".join(problems))
self._go(Step.PING, "kota ping", PING_TIMEOUT_S)
def cancel(self) -> None:
if not self.finished:
self._fail("Cancelled.")
# -------------------------------------------------------- driving
def feed(self, line: LogLine) -> None:
if self.finished:
return
if self.step == Step.RESTART and line.kind == LineKind.KOTA and line.record == "boot":
return self._on_boot(line.fields)
r = parse_reply(line)
if r is None:
return
if self.step == Step.PING and r.name == "ping":
if not r.ok or int(r.fields.get("api", "0") or 0) < API_VERSION:
return self._fail("The box answered, but its command version is not supported.")
self._emit(f"Firmware {r.fields.get('fw', '?')} accepts USB commands.", True)
if self.action == "read":
return self._go(Step.READ, "kota wifi get", REPLY_TIMEOUT_S)
if self.action == "set":
return self._go(Step.SEND, wifi_set_command(self.ssid, self.password), REPLY_TIMEOUT_S)
return self._go(Step.SEND, "kota wifi clear", REPLY_TIMEOUT_S)
if self.step == Step.READ and r.name == "wifi_get":
if not r.ok:
return self._fail(r.error)
self.info = WifiInfo.from_reply(r)
return self._finish(self.info.describe())
if self.step == Step.SEND and r.name in ("wifi_set", "wifi_clear"):
if not r.ok:
return self._fail(r.error)
self._emit("The box stored the settings and is restarting.", True)
self.step, self._deadline = Step.RESTART, self.now() + RESTART_TIMEOUT_S
return self._emit(Step.RESTART.value + "...")
if self.step == Step.CONNECT and r.name == "health" and r.ok:
return self._on_health(r.fields)
def tick(self) -> None:
"""Call periodically (e.g. every 0.5 s): timeouts and health polling."""
if self.finished:
return
t = self.now()
if self.step == Step.CONNECT and t >= self._next_poll:
self._next_poll = t + HEALTH_POLL_S
self.send("kota health")
if t < self._deadline:
return
if self.step == Step.PING:
self._fail("The box does not answer USB commands. It needs firmware with the USB console "
"(Flash firmware tab), or it is still starting - try again in 10 s.")
elif self.step == Step.RESTART:
self._fail("The box stored the settings but no restart was seen. Press Reset & capture in the "
"Serial monitor and check the Diagnosis panel.")
elif self.step == Step.CONNECT:
self._fail(f"The box did not join '{self.ssid}' within {int(CONNECT_TIMEOUT_S)} s. Check the network "
"name and password, that it is a 2.4 GHz network and that it is in range. The settings stay "
"on the box; it keeps retrying.")
else:
self._fail("The box did not answer. Check the USB connection and try again.")
# -------------------------------------------------------- internals
def _on_boot(self, f: dict[str, str]) -> None:
mode, last = f.get("mode", "?"), f.get("last_restart", "?")
want = "usb_wifi_set" if self.action == "set" else "usb_wifi_clear"
if last != want:
return self._fail(f"The box restarted for another reason ({last}); the settings may not be applied.")
self._emit(f"Restarted in {mode} mode.", True)
if self.action == "clear":
ok = mode == "matter"
return self._finish("Wi-Fi removed; the box uses Matter again (pair it in the Matter app if needed).") \
if ok else self._fail(f"The box restarted in {mode} mode instead of Matter.")
if mode != "wifi":
return self._fail(f"The box restarted in {mode} mode instead of local Wi-Fi.")
self.step = Step.CONNECT
self._deadline = self.now() + CONNECT_TIMEOUT_S
self._next_poll = self.now() + HEALTH_POLL_S
self._emit(Step.CONNECT.value + f" '{self.ssid}'...")
def _on_health(self, f: dict[str, str]) -> None:
if f.get("wifi") != "connected":
return
rssi = f.get("rssi", "?")
note = " - weak signal, consider moving the box or the router" if rssi.lstrip("-").isdigit() \
and int(rssi) < -75 else ""
self._finish(f"Connected to '{self.ssid}' (signal {rssi} dBm{note}). Data upload starts within a minute.")
def _go(self, step: Step, command: str, timeout: float) -> None:
self.step, self._deadline = step, self.now() + timeout
self._emit(step.value + "...")
if not self.send(command):
self._fail("The box is not connected. Connect it first.")
def _emit(self, text: str, ok: bool | None = None) -> None:
ev = FlowEvent(self.step, text, ok)
self._events.append(ev)
self.on_event(ev)
def _finish(self, text: str) -> None:
self.step = Step.DONE
self._emit(text, True)
def _fail(self, text: str) -> None:
self.step = Step.FAILED
self._emit(text, False)
+155 -155
View File
@@ -1,155 +1,155 @@
"""Hand-off of a diagnosis to the KOTA service system (Google Apps Script). """Hand-off of a diagnosis to the KOTA service system (Google Apps Script).
The tool opens the box's service page in the technician's browser: The tool opens the box's service page in the technician's browser:
<base><MAC>#kota-report=<base64url(JSON)> <base><MAC>#kota-report=<base64url(JSON)>
The service page (v1.1.0+) reads the fragment and pre-fills status and note; The service page (v1.1.0+) reads the fragment and pre-fills status and note;
the technician checks it, adds the location and saves - so the report is the technician checks it, adds the location and saves - so the report is
authored by their own Google login and the tool holds no credentials. authored by their own Google login and the tool holds no credentials.
The fragment is never sent to any server (redirect host or Apps Script). The fragment is never sent to any server (redirect host or Apps Script).
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
import json import json
import re import re
from .. import __version__ from .. import __version__
from ..diag.engine import Report, Severity from ..diag.engine import Report, Severity
DEFAULT_BASE = "https://service.kota-app.com/" DEFAULT_BASE = "https://service.kota-app.com/"
NOTE_LIMIT = 4800 # the service form and Code.gs accept 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). # Status names exactly as in the service system (Code.gs STATUSES).
OK = "Działa poprawnie" OK = "Działa poprawnie"
CHECK = "Do sprawdzenia" CHECK = "Do sprawdzenia"
IN_SERVICE = "W trakcie serwisu" IN_SERVICE = "W trakcie serwisu"
HARDWARE = "Błąd hardware" HARDWARE = "Błąd hardware"
SOFTWARE = "Błąd software" SOFTWARE = "Błąd software"
POWER = "Problem z zasilaniem" POWER = "Problem z zasilaniem"
CONNECTIVITY = "Problem z łącznością" CONNECTIVITY = "Problem z łącznością"
# Root-finding rule id prefix -> service status (first match wins). # Root-finding rule id prefix -> service status (first match wins).
_STATUS_BY_RULE = [ _STATUS_BY_RULE = [
("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER), ("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER),
("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE), ("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), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE), ("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE), ("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE),
("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", 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), ("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY), ("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY), ("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
# firmware 1.0.16+ (KOTA: lines, status, error IDs) # firmware 1.0.16+ (KOTA: lines, status, error IDs)
("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE), ("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
("kota.low_heap", SOFTWARE), ("kota.low_heap", SOFTWARE),
("kota.i2c_stuck", HARDWARE), ("kota.sensor_missing", HARDWARE), ("kota.rtc_", HARDWARE), ("kota.i2c_stuck", HARDWARE), ("kota.sensor_missing", HARDWARE), ("kota.rtc_", HARDWARE),
("kota.sd_", HARDWARE), ("status.error", HARDWARE), ("status.rtc", HARDWARE), ("status.sd", 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.wifi_down", CONNECTIVITY), ("kota.weak_wifi", CONNECTIVITY), ("kota.no_upload", CONNECTIVITY),
("kota.ota_unregistered", CONNECTIVITY), ("kota.buffer_backlog", CONNECTIVITY), ("status.conn", CONNECTIVITY), ("kota.ota_unregistered", CONNECTIVITY), ("kota.buffer_backlog", CONNECTIVITY), ("status.conn", CONNECTIVITY),
("kota.time_invalid", CHECK), ("kota.time_invalid", CHECK),
*((f"eid.{m}", HARDWARE) for m in ("SD", "ENV", "RTC", "BME", "SEN", "AS", "MIC", "FLK", "SPI")), *((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")), *((f"eid.{m}", CONNECTIVITY) for m in ("INF", "WIFI", "OTA", "PROV")),
("eid.", SOFTWARE), ("eid.", SOFTWARE),
("rtc.clock_not_running", CHECK), ("reboot.", CHECK), ("rtc.clock_not_running", CHECK), ("reboot.", CHECK),
] ]
_MAC = re.compile(r"^[0-9A-F]{12}$") _MAC = re.compile(r"^[0-9A-F]{12}$")
def normalize_mac(mac: str) -> str: def normalize_mac(mac: str) -> str:
return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper() return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper()
def service_status(report: Report) -> str: def service_status(report: Report) -> str:
"""Suggested status; the technician can change it in the form.""" """Suggested status; the technician can change it in the form."""
problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING] problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING]
if not problems: if not problems:
return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK
rid = problems[0].finding.rule_id rid = problems[0].finding.rule_id
for prefix, status in _STATUS_BY_RULE: for prefix, status in _STATUS_BY_RULE:
if rid.startswith(prefix): if rid.startswith(prefix):
return status return status
return CHECK return CHECK
BOX_MARK = {"ok": "✔", "warn": "!", "bad": "✖", "info": "i"} # same marks as the Diagnosis panel BOX_MARK = {"ok": "✔", "warn": "!", "bad": "✖", "info": "i"} # same marks as the Diagnosis panel
SD_WIFI = {True: "reads Wi-Fi settings from the SD card", SD_WIFI = {True: "reads Wi-Fi settings from the SD card",
False: "does not read 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"} None: "Wi-Fi-from-SD support unknown"}
def service_note(report: Report, limit: int = NOTE_LIMIT) -> str: def service_note(report: Report, limit: int = NOTE_LIMIT) -> str:
"""The Diagnosis panel as text: verdict, LED, firmware, box self-report, """The Diagnosis panel as text: verdict, LED, firmware, box self-report,
start-up progress with times, findings with details and when they were seen. 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 Falls back to shorter finding details when the full text does not fit the
service form (consequences' explanations first, then all), then truncates. service form (consequences' explanations first, then all), then truncates.
""" """
for detail in ("all", "roots", "none"): for detail in ("all", "roots", "none"):
text = _note(report, detail) text = _note(report, detail)
if len(text) <= limit: if len(text) <= limit:
return text return text
return text[:limit - 20].rstrip() + "\n...(shortened)" return text[:limit - 20].rstrip() + "\n...(shortened)"
def _note(report: Report, detail: str) -> str: def _note(report: Report, detail: str) -> str:
fw = report.firmware fw = report.firmware
source = " (reported by the box)" if report.led_exact else "" source = " (reported by the box)" if report.led_exact else ""
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", "", lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", "",
f"Status LED should be: {report.led_text()}{source}"] f"Status LED should be: {report.led_text()}{source}"]
if report.led_reason: if report.led_reason:
lines.append(f" from: {report.led_reason}") lines.append(f" from: {report.led_reason}")
lines += ["", f"Firmware: {fw.describe()}", f" {SD_WIFI[fw.supports_sd_wifi]}"] lines += ["", f"Firmware: {fw.describe()}", f" {SD_WIFI[fw.supports_sd_wifi]}"]
if report.box: if report.box:
lines += ["", "Box self-report (KOTA lines):"] lines += ["", "Box self-report (KOTA lines):"]
lines += [f" {BOX_MARK.get(st, '-')} {topic}: {text}" for topic, text, st in report.box] lines += [f" {BOX_MARK.get(st, '-')} {topic}: {text}" for topic, text, st in report.box]
lines += ["", "Start-up progress (current boot):"] lines += ["", "Start-up progress (current boot):"]
lines += [f" ✔ {label} ({t / 1000:.1f} s)" if t is not None else f" ○ {label}" lines += [f" ✔ {label} ({t / 1000:.1f} s)" if t is not None else f" ○ {label}"
for _, label, t in report.milestones] for _, label, t in report.milestones]
lines += ["", "Findings - root causes first:" if report.roots else "Findings: no problems detected in the log"] lines += ["", "Findings - root causes first:" if report.roots else "Findings: no problems detected in the log"]
for rc in report.roots: for rc in report.roots:
lines += _finding(rc.finding, "", detail in ("all", "roots")) lines += _finding(rc.finding, "", detail in ("all", "roots"))
for c in rc.consequences: for c in rc.consequences:
lines += _finding(c, " ↳ ", detail == "all") lines += _finding(c, " ↳ ", detail == "all")
for title, items in (("Seen in earlier boots only", report.earlier), for title, items in (("Seen in earlier boots only", report.earlier),
("Resolved during this session", report.resolved)): ("Resolved during this session", report.resolved)):
if items: if items:
lines += ["", f"{title} ({len(items)}):"] lines += ["", f"{title} ({len(items)}):"]
lines += [f" - [{f.severity.label}] {f.title} - {f.seen_text()}" for f in items] lines += [f" - [{f.severity.label}] {f.title} - {f.seen_text()}" for f in items]
if report.technician: if report.technician:
lines += ["", "Technician checks:"] lines += ["", "Technician checks:"]
lines += [f" * {t}" for t in report.technician[-12:]] lines += [f" * {t}" for t in report.technician[-12:]]
return "\n".join(lines) return "\n".join(lines)
def _finding(f, prefix: str, detailed: bool) -> list[str]: def _finding(f, prefix: str, detailed: bool) -> list[str]:
ind = " " * len(prefix) + " " ind = " " * len(prefix) + " "
out = [f"{prefix}[{f.severity.label}] {f.title}"] out = [f"{prefix}[{f.severity.label}] {f.title}"]
if detailed: if detailed:
out.append(f"{ind}What it means: {f.meaning}") out.append(f"{ind}What it means: {f.meaning}")
if f.actions: if f.actions:
out.append(f"{ind}What to do:") out.append(f"{ind}What to do:")
out += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)] 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 += [f"{ind}Error {eid}" + (f": {a}" if a else "") for eid, a in f.error_ids.items()]
out.append(f"{ind}{f.seen_text()}") out.append(f"{ind}{f.seen_text()}")
if f.evidence: if f.evidence:
out.append(f"{ind}Log: {f.evidence[-1]}") out.append(f"{ind}Log: {f.evidence[-1]}")
return out return out
def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str: def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str:
mac = normalize_mac(mac) mac = normalize_mac(mac)
if not _MAC.match(mac): if not _MAC.match(mac):
raise ValueError(f"not a box MAC address: {mac!r}") raise ValueError(f"not a box MAC address: {mac!r}")
payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report), payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report),
"tool": f"v{__version__}"} "tool": f"v{__version__}"}
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
base = base if base.endswith("/") else base + "/" base = base if base.endswith("/") else base + "/"
return f"{base}{mac}#kota-report={frag}" return f"{base}{mac}#kota-report={frag}"
def decode_fragment(url: str) -> dict: def decode_fragment(url: str) -> dict:
"""Inverse of report_url (used by tests and for troubleshooting).""" """Inverse of report_url (used by tests and for troubleshooting)."""
frag = url.split("#kota-report=", 1)[1] frag = url.split("#kota-report=", 1)[1]
return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8")) return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8"))
+88 -87
View File
@@ -1,87 +1,88 @@
"""Quick-start cheat sheet shown on launch. """Quick-start cheat sheet shown on launch.
LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md, LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md,
status.cpp Status::pattern_for): colour AND pattern name the fault. Older 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. boxes (v1.0, built Jun 29 2026) show the same colours, always solid.
""" """
# (swatch colour, LED name, pattern -> meaning, first action) # (swatch colour, LED name, pattern -> meaning, first action)
LED_TABLE = [ LED_TABLE = [
("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."), ("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."),
("#1fc8d8", "Cyan", ("#1fc8d8", "Cyan",
"breathing: not paired in Matter yet<br>fast blink: a phone is connected for pairing.", "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)."), "Pair the box in the Matter app (QR sticker or the QR code in the tool)."),
("#f4f4f4", "White", ("#f4f4f4", "White",
"fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>" "fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>"
"breathing: Wi-Fi setup portal.", "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."), "SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."),
("#d64541", "Red", ("#d64541", "Red",
"1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed<br>" "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.", "solid: other internal error / out of memory.",
"Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."), "Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."),
("#2f6fd6", "Blue", ("#2f6fd6", "Blue",
"1 pulse: clock (RTC) not responding<br>2 pulses: I2C bus stuck (shorted / broken sensor cable).", "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."), "Check the I2C cable between the boards first, then the RTC module."),
("#8e44ad", "Purple", ("#8e44ad", "Purple",
"solid: no network<br>blinking: server rejected the box (wrong ENV.TXT credentials).", "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."), "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."), ("#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).", ("#f08c1a", "Orange", "solid: self-check after an update (~2.5 min).",
"Wait; should turn green (or red on failure)."), "Wait; should turn green (or red on failure)."),
("#444444", "Off", "Firmware did not start (or crashed very early).", ("#444444", "Off", "Firmware did not start (or crashed very early).",
"Check power switch & USB cable. If the small LED is also dark: re-flash."), "Check power switch & USB cable. If the small LED is also dark: re-flash."),
] ]
HINTS = [ HINTS = [
"<b>Before a self-restart</b> the LED flashes the error colour 3 times; <b>after the restart</b> it " "<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 " "shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press "
"<i>Reset &amp; capture</i>: the tool reads the restart reason from the box.", "<i>Reset &amp; 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, " "<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.", "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, " "<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 " "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 " "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>[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 " "<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 " "(off = no noise measurements), and the Matter button. Holding the "
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter " "Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
"network and must be commissioned again with the sticker QR code).", "network and must be commissioned again with the sticker QR code).",
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. " "<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
"Normally not needed - the tool resets the board over USB.", "Normally not needed - the tool resets the board over USB.",
"<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build " "<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
"by its compile date.", "by its compile date.",
] ]
STEPS = [ STEPS = [
"Switch the sensorbox <b>power switch ON</b>.", "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 "
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press " "hub; USB-C only often fails). The cable must carry data.",
"<b>Connect</b>.", "Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
"Press <b>Reset &amp; capture</b> to record a full boot log from the first line.", "<b>Connect</b>.",
"Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> " "Press <b>Reset &amp; capture</b> to record a full boot log from the first line.",
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.", "Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
] "and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
]
def cheatsheet_html() -> str:
rows = "".join( def cheatsheet_html() -> str:
f"<tr><td style='padding:6px'><span style='background:{c};" rows = "".join(
f"border:1px solid #888'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>&nbsp;<b>{name}</b></td>" f"<tr><td style='padding:6px'><span style='background:{c};"
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>" f"border:1px solid #888'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>&nbsp;<b>{name}</b></td>"
for c, name, meaning, action in LED_TABLE f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
) for c, name, meaning, action in LED_TABLE
steps = "".join(f"<li>{s}</li>" for s in STEPS) )
hints = "".join(f"<li>{h}</li>" for h in HINTS) steps = "".join(f"<li>{s}</li>" for s in STEPS)
return f""" hints = "".join(f"<li>{h}</li>" for h in HINTS)
<h2>Sensorbox quick start</h2> return f"""
<ol>{steps}</ol> <h2>Sensorbox quick start</h2>
<h3>RGB status LED - colour and pattern</h3> <ol>{steps}</ol>
<table border='1' cellspacing='0' cellpadding='0' width='100%'> <h3>RGB status LED - colour and pattern</h3>
<tr><th align='left' style='padding:6px'>LED</th> <table border='1' cellspacing='0' cellpadding='0' width='100%'>
<th align='left' style='padding:6px'>Pattern: meaning</th> <tr><th align='left' style='padding:6px'>LED</th>
<th align='left' style='padding:6px'>First action</th></tr> <th align='left' style='padding:6px'>Pattern: meaning</th>
{rows} <th align='left' style='padding:6px'>First action</th></tr>
</table> {rows}
<h3>Good to know</h3> </table>
<ul>{hints}</ul> <h3>Good to know</h3>
""" <ul>{hints}</ul>
"""
+252 -252
View File
@@ -1,252 +1,252 @@
"""Diagnostics side panel: renders a diag.engine.Report. """Diagnostics side panel: renders a diag.engine.Report.
Layout: verdict banner, expected LED (colour + pattern + meaning when the Layout: verdict banner, expected LED (colour + pattern + meaning when the
box reports it), firmware, the box's own self-report (KOTA lines), start-up box reports it), firmware, the box's own self-report (KOTA lines), start-up
milestones, a findings tree (root causes; their consequences as children; milestones, a findings tree (root causes; their consequences as children;
titles only) and a wrapped detail pane for the selected finding. titles only) and a wrapped detail pane for the selected finding.
""" """
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QSplitter, QSplitter,
QTextBrowser, QTextBrowser,
QTreeWidget, QTreeWidget,
QTreeWidgetItem, QTreeWidgetItem,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from ..diag.engine import LED, Finding, Report, Severity from ..diag.engine import LED, Finding, Report, Severity
SEV_COLOR = { SEV_COLOR = {
Severity.CRITICAL: "#b03a2e", Severity.CRITICAL: "#b03a2e",
Severity.ERROR: "#d9534f", Severity.ERROR: "#d9534f",
Severity.WARNING: "#c77c02", Severity.WARNING: "#c77c02",
Severity.INFO: "#2e6da4", Severity.INFO: "#2e6da4",
} }
# verdict banner (background, text) by verdict severity; None = healthy # verdict banner (background, text) by verdict severity; None = healthy
BANNER = { BANNER = {
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"), Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
Severity.ERROR: ("#f9dedc", "#8a2019"), Severity.ERROR: ("#f9dedc", "#8a2019"),
Severity.WARNING: ("#fdebc8", "#6b4400"), Severity.WARNING: ("#fdebc8", "#6b4400"),
None: ("#d8f0dd", "#1b5e2a"), None: ("#d8f0dd", "#1b5e2a"),
} }
_KEY = Qt.ItemDataRole.UserRole _KEY = Qt.ItemDataRole.UserRole
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")} BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
def _heading(text: str) -> QLabel: def _heading(text: str) -> QLabel:
lbl = QLabel(text) lbl = QLabel(text)
f = lbl.font() f = lbl.font()
f.setBold(True) f.setBold(True)
lbl.setFont(f) lbl.setFont(f)
return lbl return lbl
def _esc(s: str) -> str: def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class DiagnosticsPanel(QWidget): class DiagnosticsPanel(QWidget):
def __init__(self, parent: QWidget | None = None): def __init__(self, parent: QWidget | None = None):
super().__init__(parent) super().__init__(parent)
self.setMinimumWidth(380) self.setMinimumWidth(380)
self.verdict = QLabel(wordWrap=True) self.verdict = QLabel(wordWrap=True)
self.verdict.setMargin(10) self.verdict.setMargin(10)
vf = self.verdict.font() vf = self.verdict.font()
vf.setPointSize(vf.pointSize() + 2) vf.setPointSize(vf.pointSize() + 2)
vf.setBold(True) vf.setBold(True)
self.verdict.setFont(vf) self.verdict.setFont(vf)
self.led_swatch = QLabel() self.led_swatch = QLabel()
self.led_swatch.setFixedSize(22, 22) self.led_swatch.setFixedSize(22, 22)
self.led_text = QLabel(wordWrap=True) self.led_text = QLabel(wordWrap=True)
led_row = QHBoxLayout() led_row = QHBoxLayout()
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop) led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
led_row.addWidget(self.led_text, 1) led_row.addWidget(self.led_text, 1)
self.firmware = QLabel(wordWrap=True) self.firmware = QLabel(wordWrap=True)
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.box_title = _heading("Box self-report (KOTA lines)") self.box_title = _heading("Box self-report (KOTA lines)")
self.box = QLabel(wordWrap=True) self.box = QLabel(wordWrap=True)
self.box.setTextFormat(Qt.TextFormat.RichText) self.box.setTextFormat(Qt.TextFormat.RichText)
self.box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.milestones = QListWidget() self.milestones = QListWidget()
self.milestones.setFrameShape(QFrame.Shape.NoFrame) self.milestones.setFrameShape(QFrame.Shape.NoFrame)
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection) self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
self.tree = QTreeWidget() self.tree = QTreeWidget()
self.tree.setHeaderHidden(True) self.tree.setHeaderHidden(True)
self.tree.setIndentation(16) self.tree.setIndentation(16)
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight) self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
self.detail = QTextBrowser() self.detail = QTextBrowser()
self.detail.setOpenExternalLinks(False) self.detail.setOpenExternalLinks(False)
findings = QSplitter(Qt.Orientation.Vertical) findings = QSplitter(Qt.Orientation.Vertical)
findings.addWidget(self.tree) findings.addWidget(self.tree)
findings.addWidget(self.detail) findings.addWidget(self.detail)
findings.setSizes([220, 260]) findings.setSizes([220, 260])
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(4, 0, 0, 0) lay.setContentsMargins(4, 0, 0, 0)
lay.addWidget(self.verdict) lay.addWidget(self.verdict)
lay.addWidget(_heading("Status LED should be")) lay.addWidget(_heading("Status LED should be"))
lay.addLayout(led_row) lay.addLayout(led_row)
lay.addWidget(_heading("Firmware")) lay.addWidget(_heading("Firmware"))
lay.addWidget(self.firmware) lay.addWidget(self.firmware)
self.qr_btn = QPushButton("Matter pairing QR code...") self.qr_btn = QPushButton("Matter pairing QR code...")
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone") self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft) lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
lay.addWidget(self.box_title) lay.addWidget(self.box_title)
lay.addWidget(self.box) lay.addWidget(self.box)
lay.addWidget(_heading("Start-up progress (current boot)")) lay.addWidget(_heading("Start-up progress (current boot)"))
lay.addWidget(self.milestones) lay.addWidget(self.milestones)
lay.addWidget(_heading("Findings - root causes first; click one for details")) lay.addWidget(_heading("Findings - root causes first; click one for details"))
lay.addWidget(findings, 1) lay.addWidget(findings, 1)
self._findings: dict[str, Finding] = {} self._findings: dict[str, Finding] = {}
self._selected: str | None = None self._selected: str | None = None
# key -> expanded, chosen by the user; survives the rebuild on refresh. # key -> expanded, chosen by the user; survives the rebuild on refresh.
self._expanded: dict[str, bool] = {} self._expanded: dict[str, bool] = {}
self.tree.currentItemChanged.connect(self._on_select) self.tree.currentItemChanged.connect(self._on_select)
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False)) self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
self.tree.itemExpanded.connect(lambda it: self._remember(it, True)) self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def show_report(self, r: Report) -> None: def show_report(self, r: Report) -> None:
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING]) bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
if r.lines == 0: if r.lines == 0:
bg, fg = "#e6e6e6", "#333333" bg, fg = "#e6e6e6", "#333333"
self.verdict.setText(r.verdict) self.verdict.setText(r.verdict)
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}") self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
name, colour = LED[r.led] name, colour = LED[r.led]
self.led_swatch.setStyleSheet( self.led_swatch.setStyleSheet(
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}") f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>" ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else "" pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else ""
meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning 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 "" source = " <small style='color:#1b7a33'>(reported by the box)</small>" if r.led_exact else ""
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else "" reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
self.led_text.setText(f"<b>{name}{pattern}</b>{meaning}{source}{ask}{reason}") self.led_text.setText(f"<b>{name}{pattern}</b>{meaning}{source}{ask}{reason}")
fw = r.firmware fw = r.firmware
sd = {True: "reads Wi-Fi settings from the SD card", sd = {True: "reads Wi-Fi settings from the SD card",
False: "does not read 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"} None: "Wi-Fi-from-SD support unknown"}
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>") self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
self.box_title.setVisible(bool(r.box)) self.box_title.setVisible(bool(r.box))
self.box.setVisible(bool(r.box)) self.box.setVisible(bool(r.box))
self.box.setText("<table cellspacing='0' cellpadding='1'>" + "".join( self.box.setText("<table cellspacing='0' cellpadding='1'>" + "".join(
f"<tr><td style='color:{BOX_STATE[st][0]}'><b>{BOX_STATE[st][1]}</b>&nbsp;</td>" f"<tr><td style='color:{BOX_STATE[st][0]}'><b>{BOX_STATE[st][1]}</b>&nbsp;</td>"
f"<td><b>{_esc(topic)}</b>&nbsp;</td><td>{_esc(text)}</td></tr>" f"<td><b>{_esc(topic)}</b>&nbsp;</td><td>{_esc(text)}</td></tr>"
for topic, text, st in r.box) + "</table>") for topic, text, st in r.box) + "</table>")
self.milestones.clear() self.milestones.clear()
for _, label, t in r.milestones: for _, label, t in r.milestones:
done = t is not None done = t is not None
it = QListWidgetItem(("✔ " if done else "○ ") + label it = QListWidgetItem(("✔ " if done else "○ ") + label
+ (f" ({t / 1000:.1f} s)" if done else "")) + (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 "#8a8a8a")))
self.milestones.addItem(it) self.milestones.addItem(it)
rows = min(max(self.milestones.count(), 1), 15) rows = min(max(self.milestones.count(), 1), 15)
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6) self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
self._fill_tree(r) self._fill_tree(r)
def _fill_tree(self, r: Report) -> None: def _fill_tree(self, r: Report) -> None:
self.tree.blockSignals(True) self.tree.blockSignals(True)
self.tree.clear() self.tree.clear()
self._findings = {} self._findings = {}
if not r.roots: if not r.roots:
it = QTreeWidgetItem(["No problems detected in the log so far."]) it = QTreeWidgetItem(["No problems detected in the log so far."])
it.setForeground(0, QBrush(QColor("#1b7a33"))) it.setForeground(0, QBrush(QColor("#1b7a33")))
self.tree.addTopLevelItem(it) self.tree.addTopLevelItem(it)
for rc in r.roots: for rc in r.roots:
top = self._item(rc.finding, bold=True) top = self._item(rc.finding, bold=True)
self.tree.addTopLevelItem(top) self.tree.addTopLevelItem(top)
for c in rc.consequences: for c in rc.consequences:
top.addChild(self._item(c, prefix="↳ ")) top.addChild(self._item(c, prefix="↳ "))
top.setExpanded(self._expanded.get(rc.finding.key, True)) top.setExpanded(self._expanded.get(rc.finding.key, True))
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier), for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
("Resolved during this session", "grp:resolved", r.resolved)): ("Resolved during this session", "grp:resolved", r.resolved)):
if not items: if not items:
continue continue
grp = QTreeWidgetItem([f"{title} ({len(items)})"]) grp = QTreeWidgetItem([f"{title} ({len(items)})"])
grp.setData(0, _KEY, key) grp.setData(0, _KEY, key)
grp.setForeground(0, QBrush(QColor("#666666"))) grp.setForeground(0, QBrush(QColor("#666666")))
self.tree.addTopLevelItem(grp) self.tree.addTopLevelItem(grp)
for f in items: for f in items:
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else "")) grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
grp.setExpanded(self._expanded.get(key, False)) grp.setExpanded(self._expanded.get(key, False))
# Keep the selection across refreshes; default to the top root cause. # Keep the selection across refreshes; default to the top root cause.
target = self._selected if self._selected in self._findings else ( target = self._selected if self._selected in self._findings else (
r.roots[0].finding.key if r.roots else None) r.roots[0].finding.key if r.roots else None)
self.tree.blockSignals(False) self.tree.blockSignals(False)
self._select(target) self._select(target)
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None: def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
if key := item.data(0, _KEY): if key := item.data(0, _KEY):
self._expanded[key] = expanded self._expanded[key] = expanded
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem: def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"]) it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
it.setData(0, _KEY, f.key) it.setData(0, _KEY, f.key)
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333")))) it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
it.setToolTip(0, f.title) it.setToolTip(0, f.title)
if bold: if bold:
font = it.font(0) font = it.font(0)
font.setBold(True) font.setBold(True)
it.setFont(0, font) it.setFont(0, font)
self._findings[f.key] = f self._findings[f.key] = f
return it return it
def _select(self, key: str | None) -> None: def _select(self, key: str | None) -> None:
if key is None: if key is None:
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>") self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
return return
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive) match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
for it in match: for it in match:
if it.data(0, _KEY) == key: if it.data(0, _KEY) == key:
self.tree.setCurrentItem(it) self.tree.setCurrentItem(it)
return return
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None: def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
key = cur.data(0, _KEY) if cur else None key = cur.data(0, _KEY) if cur else None
f = self._findings.get(key or "") f = self._findings.get(key or "")
if f is None: if f is None:
return return
self._selected = key self._selected = key
self.detail.setHtml(_detail_html(f)) self.detail.setHtml(_detail_html(f))
def _detail_html(f: Finding) -> str: def _detail_html(f: Finding) -> str:
colour = SEV_COLOR.get(f.severity, "#333") colour = SEV_COLOR.get(f.severity, "#333")
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions) actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
seen = _esc(f.seen_text()) seen = _esc(f.seen_text())
evidence = "<br>".join(_esc(e) for e in f.evidence) evidence = "<br>".join(_esc(e) for e in f.evidence)
codes = "".join(f"<li><b>{_esc(eid)}</b>" + (f" - {_esc(a)}" if a else "") + "</li>" codes = "".join(f"<li><b>{_esc(eid)}</b>" + (f" - {_esc(a)}" if a else "") + "</li>"
for eid, a in f.error_ids.items()) for eid, a in f.error_ids.items())
return ( return (
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>" f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>" f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "") + (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
+ (f"<p><b>Firmware error codes</b> (error_ids.csv):</p><ul>{codes}</ul>" if codes else "") + (f"<p><b>Firmware error codes</b> (error_ids.csv):</p><ul>{codes}</ul>" if codes else "")
+ f"<p style='color:#666'>{seen}</p>" + f"<p style='color:#666'>{seen}</p>"
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>" + (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
if evidence else "") if evidence else "")
) )
+364 -364
View File
@@ -1,364 +1,364 @@
"""Flash firmware tab: only the .bin files in the tool's firmware/ folder, """Flash firmware tab: only the .bin files in the tool's firmware/ folder,
chosen by their compile date; App-only by default.""" chosen by their compile date; App-only by default."""
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QButtonGroup, QButtonGroup,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMessageBox, QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QProgressBar, QProgressBar,
QPushButton, QPushButton,
QRadioButton, QRadioButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from ..core.session import SESSIONS_DIR from ..core.session import SESSIONS_DIR
from ..diag.firmware import local_build, register_local_build, same_build from ..diag.firmware import local_build, register_local_build, same_build
from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_images from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_images
from ..flash.plan import Mode, build_plan from ..flash.plan import Mode, build_plan
from ..flash.runner import FlashRunner, Result from ..flash.runner import FlashRunner, Result
from ..serialio.ports import PortInfo, list_serial_ports from ..serialio.ports import PortInfo, list_serial_ports
BACKUP_DIR = SESSIONS_DIR.parent / "backups" BACKUP_DIR = SESSIONS_DIR.parent / "backups"
VERIFY_TIMEOUT_MS = 45_000 VERIFY_TIMEOUT_MS = 45_000
HELP = ( 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>, " "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." "press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
) )
class _Bridge(QObject): class _Bridge(QObject):
step = Signal(int, str) step = Signal(int, str)
progress = Signal(int) progress = Signal(int)
output = Signal(str) output = Signal(str)
done = Signal(object) done = Signal(object)
class FlashTab(QWidget): class FlashTab(QWidget):
def __init__(self, serial_tab, parent: QWidget | None = None): def __init__(self, serial_tab, parent: QWidget | None = None):
super().__init__(parent) super().__init__(parent)
self.serial_tab = serial_tab self.serial_tab = serial_tab
self._images: list[FirmwareImage] = [] self._images: list[FirmwareImage] = []
self._ports: list[PortInfo] = [] # before any signal can call _update() self._ports: list[PortInfo] = [] # before any signal can call _update()
self._runner: FlashRunner | None = None self._runner: FlashRunner | None = None
self._port: PortInfo | None = None self._port: PortInfo | None = None
self._image: FirmwareImage | None = None self._image: FirmwareImage | None = None
# 1. box # 1. box
box = QGroupBox("1. Sensorbox") box = QGroupBox("1. Sensorbox")
self.port_combo = QComboBox(minimumWidth=360) self.port_combo = QComboBox(minimumWidth=360)
refresh = QPushButton("Refresh") refresh = QPushButton("Refresh")
refresh.clicked.connect(self.refresh_ports) refresh.clicked.connect(self.refresh_ports)
self.box_fw = QLabel(wordWrap=True) self.box_fw = QLabel(wordWrap=True)
bl = QVBoxLayout(box) bl = QVBoxLayout(box)
row = QHBoxLayout() row = QHBoxLayout()
row.addWidget(self.port_combo) row.addWidget(self.port_combo)
row.addWidget(refresh) row.addWidget(refresh)
row.addStretch(1) row.addStretch(1)
bl.addLayout(row) bl.addLayout(row)
bl.addWidget(self.box_fw) bl.addWidget(self.box_fw)
# 2. firmware - only files in the tool's firmware/ folder # 2. firmware - only files in the tool's firmware/ folder
fwbox = QGroupBox("2. Firmware (by compile date)") fwbox = QGroupBox("2. Firmware (by compile date)")
self.image_combo = QComboBox(minimumWidth=360) self.image_combo = QComboBox(minimumWidth=360)
self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}") self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
self.reload_btn = QPushButton("Refresh list") 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.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.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
self.folder_btn = QPushButton("Open firmware folder") self.folder_btn = QPushButton("Open firmware folder")
self.folder_btn.clicked.connect( self.folder_btn.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR)))) lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
self.image_info = QLabel(wordWrap=True) self.image_info = QLabel(wordWrap=True)
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
fl = QVBoxLayout(fwbox) fl = QVBoxLayout(fwbox)
fl.addWidget(self.image_combo) fl.addWidget(self.image_combo)
brow = QHBoxLayout() brow = QHBoxLayout()
brow.addWidget(self.reload_btn) brow.addWidget(self.reload_btn)
brow.addWidget(self.folder_btn) brow.addWidget(self.folder_btn)
brow.addStretch(1) brow.addStretch(1)
fl.addLayout(brow) fl.addLayout(brow)
fl.addWidget(self.image_info) fl.addWidget(self.image_info)
self._problems: list[str] = [] self._problems: list[str] = []
# 3. mode # 3. mode
modebox = QGroupBox("3. What to write") modebox = QGroupBox("3. What to write")
self.rb_app = QRadioButton("App only (recommended for repairs)") self.rb_app = QRadioButton("App only (recommended for repairs)")
self.rb_full = QRadioButton("Full 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 = QButtonGroup(self)
self.mode_group.addButton(self.rb_app) self.mode_group.addButton(self.rb_app)
self.mode_group.addButton(self.rb_full) self.mode_group.addButton(self.rb_full)
self.rb_app.setChecked(True) self.rb_app.setChecked(True)
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the " app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
"settings stored on the box.", wordWrap=True) "settings stored on the box.", wordWrap=True)
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA " full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
"registration</b> - the box must be paired again afterwards.", wordWrap=True) "registration</b> - the box must be paired again afterwards.", wordWrap=True)
for n in (app_note, full_note): for n in (app_note, full_note):
n.setContentsMargins(24, 0, 0, 6) n.setContentsMargins(24, 0, 0, 6)
n.setStyleSheet("color:#555") n.setStyleSheet("color:#555")
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased") self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)") self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
ml = QVBoxLayout(modebox) ml = QVBoxLayout(modebox)
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup): for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
ml.addWidget(w) ml.addWidget(w)
# 4. run # 4. run
runbox = QGroupBox("4. Flash") runbox = QGroupBox("4. Flash")
self.plan_label = QLabel(wordWrap=True) self.plan_label = QLabel(wordWrap=True)
self.start_btn = QPushButton("Start flashing") self.start_btn = QPushButton("Start flashing")
self.start_btn.setMinimumHeight(36) self.start_btn.setMinimumHeight(36)
self.start_btn.clicked.connect(self._start) self.start_btn.clicked.connect(self._start)
self.step_label = QLabel() self.step_label = QLabel()
self.bar = QProgressBar() self.bar = QProgressBar()
self.result = QLabel(wordWrap=True) self.result = QLabel(wordWrap=True)
self.result.setMargin(8) self.result.setMargin(8)
self.result.setTextFormat(Qt.TextFormat.RichText) self.result.setTextFormat(Qt.TextFormat.RichText)
self.details_btn = QPushButton("Show esptool output") self.details_btn = QPushButton("Show esptool output")
self.details_btn.setCheckable(True) self.details_btn.setCheckable(True)
self.output = QPlainTextEdit(readOnly=True) self.output = QPlainTextEdit(readOnly=True)
self.output.setVisible(False) self.output.setVisible(False)
self.details_btn.toggled.connect(self.output.setVisible) self.details_btn.toggled.connect(self.output.setVisible)
rl = QVBoxLayout(runbox) rl = QVBoxLayout(runbox)
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output): for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
rl.addWidget(w) rl.addWidget(w)
help_lbl = QLabel(HELP, wordWrap=True) help_lbl = QLabel(HELP, wordWrap=True)
help_lbl.setStyleSheet("color:#555") help_lbl.setStyleSheet("color:#555")
rl.addWidget(help_lbl) rl.addWidget(help_lbl)
rl.addStretch(1) # keep the run column packed at the top rl.addStretch(1) # keep the run column packed at the top
left = QVBoxLayout() left = QVBoxLayout()
for w in (box, fwbox, modebox): for w in (box, fwbox, modebox):
left.addWidget(w) left.addWidget(w)
left.addStretch(1) left.addStretch(1)
lay = QHBoxLayout(self) lay = QHBoxLayout(self)
lay.addLayout(left, 1) lay.addLayout(left, 1)
lay.addWidget(runbox, 1) lay.addWidget(runbox, 1)
self._bridge = _Bridge() self._bridge = _Bridge()
self._bridge.step.connect(self._on_step) self._bridge.step.connect(self._on_step)
self._bridge.progress.connect(self.bar.setValue) self._bridge.progress.connect(self.bar.setValue)
self._bridge.output.connect(self.output.appendPlainText) self._bridge.output.connect(self.output.appendPlainText)
self._bridge.done.connect(self._on_done) self._bridge.done.connect(self._on_done)
self._verify_timer = QTimer(self, interval=500) self._verify_timer = QTimer(self, interval=500)
self._verify_timer.timeout.connect(self._check_verify) self._verify_timer.timeout.connect(self._check_verify)
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled): for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
sig.connect(self._update) sig.connect(self._update)
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
self.image_combo.currentIndexChanged.connect(self._update) self.image_combo.currentIndexChanged.connect(self._update)
self.port_combo.currentIndexChanged.connect(self._update) self.port_combo.currentIndexChanged.connect(self._update)
self._load_images() self._load_images()
self.refresh_ports() self.refresh_ports()
self._show_result("", None) self._show_result("", None)
# ------------------------------------------------------------ setup # ------------------------------------------------------------ setup
def _load_images(self, select_sha: str | None = None) -> None: def _load_images(self, select_sha: str | None = None) -> None:
self._images, self._problems = folder_images() self._images, self._problems = folder_images()
for img in self._images: # lets the Serial monitor show a box build's compile date 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) register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
self.image_combo.blockSignals(True) self.image_combo.blockSignals(True)
self.image_combo.clear() self.image_combo.clear()
for img in self._images: for img in self._images:
self.image_combo.addItem(img.label()) self.image_combo.addItem(img.label())
if not self._images: if not self._images:
self.image_combo.addItem("No firmware file in the firmware folder") 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) 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.setCurrentIndex(idx) # default: newest compile date
self.image_combo.blockSignals(False) self.image_combo.blockSignals(False)
self._update() self._update()
def _selected_sha(self) -> str | None: def _selected_sha(self) -> str | None:
img = self._selected_image() img = self._selected_image()
return img.sha256 if img else None return img.sha256 if img else None
def refresh_ports(self) -> None: def refresh_ports(self) -> None:
current = self.port_combo.currentData() current = self.port_combo.currentData()
self._ports = list_serial_ports() self._ports = list_serial_ports()
self.port_combo.clear() self.port_combo.clear()
for p in self._ports: for p in self._ports:
self.port_combo.addItem(p.label, p.device) self.port_combo.addItem(p.label, p.device)
if not self._ports: if not self._ports:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0)) self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
self._update() self._update()
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
self.refresh_ports() self.refresh_ports()
super().showEvent(event) super().showEvent(event)
# ------------------------------------------------------------ state # ------------------------------------------------------------ state
def _selected_image(self) -> FirmwareImage | None: def _selected_image(self) -> FirmwareImage | None:
i = self.image_combo.currentIndex() i = self.image_combo.currentIndex()
return self._images[i] if 0 <= i < len(self._images) else None return self._images[i] if 0 <= i < len(self._images) else None
def _selected_port(self) -> PortInfo | None: def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData() dev = self.port_combo.currentData()
return next((p for p in self._ports if p.device == dev), None) return next((p for p in self._ports if p.device == dev), None)
def _update(self) -> None: def _update(self) -> None:
img = self._selected_image() img = self._selected_image()
info = "" info = ""
if img: if img:
a = img.app a = img.app
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only" kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
info = (f"<b>Compiled {a.built}</b> &middot; {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>" info = (f"<b>Compiled {a.built}</b> &middot; {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
f"<small>file firmware/{img.name} &middot; build {a.elf_sha[:8]} &middot; " f"<small>file firmware/{img.name} &middot; build {a.elf_sha[:8]} &middot; "
f"SHA-256 {img.sha256[:16]}... &middot; checksum OK</small>") f"SHA-256 {img.sha256[:16]}... &middot; checksum OK</small>")
else: else:
info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>" 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.") "and press <b>Refresh list</b>. Only files in this folder can be flashed.")
if self._problems: if self._problems:
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> " info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
+ "; ".join(self._problems) + "</span>") + "; ".join(self._problems) + "</span>")
self.image_info.setText(info) self.image_info.setText(info)
# An app-only file has no bootloader/layout: Full mode is impossible. # An app-only file has no bootloader/layout: Full mode is impossible.
full_ok = bool(img and img.kind == "merged") full_ok = bool(img and img.kind == "merged")
self.rb_full.setEnabled(full_ok and self._runner is None) self.rb_full.setEnabled(full_ok and self._runner is None)
if not full_ok and self.rb_full.isChecked(): if not full_ok and self.rb_full.isChecked():
self.rb_app.setChecked(True) self.rb_app.setChecked(True)
self.box_fw.setText(self._box_text(img)) self.box_fw.setText(self._box_text(img))
self.confirm_full.setVisible(self.rb_full.isChecked()) self.confirm_full.setVisible(self.rb_full.isChecked())
ok = bool(img and self._selected_port() and self._runner is None ok = bool(img and self._selected_port() and self._runner is None
and (self.rb_app.isChecked() or self.confirm_full.isChecked())) and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
self.start_btn.setEnabled(ok) self.start_btn.setEnabled(ok)
if img and self._selected_port(): if img and self._selected_port():
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else [] steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
if mode == Mode.APP_ONLY: if mode == Mode.APP_ONLY:
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"] steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
else: else:
steps += ["Write the complete image (~25 s)"] steps += ["Write the complete image (~25 s)"]
steps += ["Restart the box and verify the new firmware boots"] 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>") self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
else: else:
self.plan_label.setText("Select a sensorbox and a firmware image.") self.plan_label.setText("Select a sensorbox and a firmware image.")
def _box_text(self, img: FirmwareImage | None) -> str: def _box_text(self, img: FirmwareImage | None) -> str:
fw = self.serial_tab.engine.firmware fw = self.serial_tab.engine.firmware
if not fw.elf_sha: if not fw.elf_sha:
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it." 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()}" msg = f"Box currently runs: {fw.describe()}"
if not img: if not img:
return msg return msg
if same_build(fw.elf_sha, img.app.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>" return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
local = local_build(fw.elf_sha) 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 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: 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>" 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: if box_built and img_built and img_built < box_built:
return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was " return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was "
"probably updated over OTA). The box updates itself again once online.</b>") "probably updated over OTA). The box updates itself again once online.</b>")
return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>" return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>"
# ------------------------------------------------------------ flash # ------------------------------------------------------------ flash
def _start(self) -> None: def _start(self) -> None:
img, port = self._selected_image(), self._selected_port() img, port = self._selected_image(), self._selected_port()
if not img or not port: if not img or not port:
return return
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL 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 what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
else "the COMPLETE image (pairing and OTA registration are erased)") else "the COMPLETE image (pairing and OTA registration are erased)")
if QMessageBox.question(self, "Flash firmware", if QMessageBox.question(self, "Flash firmware",
f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\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" f"File: firmware/{img.name}\nBox: {port.label}\n\n"
"Keep the box powered and the USB cable connected until it finishes.", "Keep the box powered and the USB cable connected until it finishes.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
) != QMessageBox.StandardButton.Ok: ) != QMessageBox.StandardButton.Ok:
return return
backup_to = None backup_to = None
if self.backup.isChecked(): if self.backup.isChecked():
BACKUP_DIR.mkdir(parents=True, exist_ok=True) BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin" backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to) plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
self._port, self._image = port, img self._port, self._image = port, img
self.serial_tab.release_port() # esptool needs the port exclusively self.serial_tab.release_port() # esptool needs the port exclusively
self.output.clear() self.output.clear()
self.output.appendPlainText("\n".join(plan.summary())) self.output.appendPlainText("\n".join(plan.summary()))
self._show_result("", None) self._show_result("", None)
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit, self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
self._bridge.output.emit, self._bridge.done.emit) self._bridge.output.emit, self._bridge.done.emit)
self._set_busy(True) self._set_busy(True)
self._runner.start() self._runner.start()
def _set_busy(self, busy: bool) -> None: def _set_busy(self, busy: bool) -> None:
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full, for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full,
self.reload_btn): self.reload_btn):
w.setEnabled(not busy) w.setEnabled(not busy)
self._update() self._update()
def _on_step(self, i: int, title: str) -> None: def _on_step(self, i: int, title: str) -> None:
n = len(self._runner.plan.steps) if self._runner else 0 n = len(self._runner.plan.steps) if self._runner else 0
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}") self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
def _on_done(self, res: Result) -> None: def _on_done(self, res: Result) -> None:
self._runner = None self._runner = None
if not res.ok: if not res.ok:
self._set_busy(False) self._set_busy(False)
self.step_label.setText("Stopped.") self.step_label.setText("Stopped.")
hint = f"<p>{res.hint}</p>" if res.hint else "" hint = f"<p>{res.hint}</p>" if res.hint else ""
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error") self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
self.details_btn.setChecked(True) self.details_btn.setChecked(True)
return return
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...") self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
self.bar.setValue(0) self.bar.setValue(0)
self._show_result("Firmware written. Waiting for the box to start...", "info") self._show_result("Firmware written. Waiting for the box to start...", "info")
self.serial_tab.connect_port(self._port, capture=True) self.serial_tab.connect_port(self._port, capture=True)
self._verify_ms = 0 self._verify_ms = 0
self._verify_timer.start() self._verify_timer.start()
def _check_verify(self) -> None: def _check_verify(self) -> None:
self._verify_ms += self._verify_timer.interval() self._verify_ms += self._verify_timer.interval()
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS))) self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
fw = self.serial_tab.engine.firmware fw = self.serial_tab.engine.firmware
want = self._image.app.elf_sha[:8] want = self._image.app.elf_sha[:8]
done = same_build(fw.elf_sha, self._image.app.elf_sha) done = same_build(fw.elf_sha, self._image.app.elf_sha)
if done or self._verify_ms >= VERIFY_TIMEOUT_MS: if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
self._verify_timer.stop() self._verify_timer.stop()
self._set_busy(False) self._set_busy(False)
self.bar.setValue(100) self.bar.setValue(100)
if done: if done:
self.step_label.setText("Done.") self.step_label.setText("Done.")
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). " self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
"Check the Diagnosis panel in the Serial monitor.", "ok") "Check the Diagnosis panel in the Serial monitor.", "ok")
else: else:
seen = fw.elf_sha[:8] 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}. " self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
"Open the Serial monitor and press Reset & capture.", "warning") "Open the Serial monitor and press Reset & capture.", "warning")
def _show_result(self, html: str, kind: str | None) -> None: def _show_result(self, html: str, kind: str | None) -> None:
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"), colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")} "warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
self.result.setVisible(bool(html)) self.result.setVisible(bool(html))
if html: if html:
border, bg = colours[kind or "info"] border, bg = colours[kind or "info"]
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}") self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html) self.result.setText(html)
def _parse_built(s: str | None) -> dt.datetime | None: def _parse_built(s: str | None) -> dt.datetime | None:
"""'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime.""" """'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
if not s: if not s:
return None return None
try: try:
return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S") return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
except ValueError: except ValueError:
return None return None
+464 -424
View File
@@ -1,424 +1,464 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QFileDialog, QFileDialog,
QHBoxLayout, QHBoxLayout,
QInputDialog, QInputDialog,
QLabel, QLabel,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QPushButton, QPushButton,
QSplitter, QSplitter,
QTabWidget, QTabWidget,
QTextBrowser, QTextBrowser,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from .. import __version__ from .. import __version__
from ..core.logfile import read_log_file from ..core.logfile import read_log_file
from ..core.logline import LineKind, LogLine, host_event from ..core.logline import LineKind, LogLine, host_event
from ..core.session import SessionLog from ..core.session import SessionLog
from ..diag.engine import DiagnosticsEngine from ..diag.engine import DiagnosticsEngine
from ..diag.guides import GuideRunner from ..diag.guides import GuideRunner
from ..serialio.manager import SerialManager, State from ..serialio.commands import loggable
from ..serialio.ports import PortInfo, list_serial_ports from ..serialio.manager import SerialManager, State
from ..matter.payload import SetupPayload, payload_from_log from ..serialio.ports import PortInfo, list_serial_ports
from ..service.report import DEFAULT_BASE, normalize_mac, report_url from ..matter.payload import SetupPayload, payload_from_log
from .cheatsheet import cheatsheet_html from ..service.report import DEFAULT_BASE, normalize_mac, report_url
from .diag_panel import DiagnosticsPanel from .cheatsheet import cheatsheet_html
from .flash_tab import FlashTab from .diag_panel import DiagnosticsPanel
from .matter_qr import MatterQrDialog from .flash_tab import FlashTab
from .guide_panel import GuidePanel from .matter_qr import MatterQrDialog
from .sd_tab import SdTab from .guide_panel import GuidePanel
from .sd_tab import SdTab
MAX_LINES = 20000 from .wifi_tab import WifiTab
# "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA", MAX_LINES = 20000
"Warnings + errors": {"W", "E"}, "Errors only": {"E"}} # "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
COLORS = { LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
"E": "#ff6b6b", "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
"W": "#f5c451", COLORS = {
"I": "#d4d4d4", "E": "#ff6b6b",
LineKind.BOOT: "#6fc3ff", "W": "#f5c451",
LineKind.RESET: "#6fc3ff", "I": "#d4d4d4",
LineKind.PANIC: "#ff4dd2", LineKind.BOOT: "#6fc3ff",
LineKind.HOST: "#7ee07e", LineKind.RESET: "#6fc3ff",
LineKind.KOTA: "#5fd7e8", LineKind.PANIC: "#ff4dd2",
LineKind.RAW: "#a8a8a8", LineKind.HOST: "#7ee07e",
} LineKind.KOTA: "#5fd7e8",
LineKind.RAW: "#a8a8a8",
}
class _Bridge(QObject):
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
class _Bridge(QObject):
line = Signal(object) """Marshals SerialManager callbacks (reader thread) onto the UI thread."""
state = Signal(object, str)
line = Signal(object)
state = Signal(object, str)
class SerialTab(QWidget):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent) class SerialTab(QWidget):
self._bridge = _Bridge() def __init__(self, parent: QWidget | None = None):
self._bridge.line.connect(self._on_line) super().__init__(parent)
self._bridge.state.connect(self._on_state) self._bridge = _Bridge()
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit) self._bridge.line.connect(self._on_line)
self._session = SessionLog() self._bridge.state.connect(self._on_state)
self._ports: list[PortInfo] = [] self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
self._counts = {"E": 0, "W": 0, "boots": 0} self._session = SessionLog()
self._engine = DiagnosticsEngine() self._ports: list[PortInfo] = []
self._diag_dirty = True self._counts = {"E": 0, "W": 0, "boots": 0}
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log) self._engine = DiagnosticsEngine()
self._diag_dirty = True
# --- controls self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
self.port_combo = QComboBox(minimumWidth=320) self._line_listeners: list = [] # other tabs following the live log (Wi-Fi over USB)
self.all_ports = QCheckBox("Show all ports")
self.refresh_btn = QPushButton("Refresh") # --- controls
self.connect_btn = QPushButton("Connect") self.port_combo = QComboBox(minimumWidth=320)
self.reset_btn = QPushButton("Reset && capture") self.all_ports = QCheckBox("Show all ports")
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line") self.refresh_btn = QPushButton("Refresh")
self.filter_combo = QComboBox() self.connect_btn = QPushButton("Connect")
self.filter_combo.addItems(LEVEL_FILTERS) self.reset_btn = QPushButton("Reset && capture")
self.clear_btn = QPushButton("Clear view") self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
self.save_btn = QPushButton("Save view as...") self.filter_combo = QComboBox()
self.folder_btn = QPushButton("Open logs folder") self.filter_combo.addItems(LEVEL_FILTERS)
self.analyze_btn = QPushButton("Analyze log file...") self.clear_btn = QPushButton("Clear view")
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it") self.save_btn = QPushButton("Save view as...")
self.copy_btn = QPushButton("Copy report") self.folder_btn = QPushButton("Open logs folder")
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)") self.analyze_btn = QPushButton("Analyze log file...")
self.service_btn = QPushButton("Service report...") self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis " self.copy_btn = QPushButton("Copy report")
"filled in; check it and press Save there") self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
self._settings = QSettings("KOTA", "TechnicianTool") self.service_btn = QPushButton("Service report...")
self.auto_service = QCheckBox("Open service report on disconnect") self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool)) "filled in; check it and press Save there")
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on)) self._settings = QSettings("KOTA", "TechnicianTool")
self._active_port: PortInfo | None = None self.auto_service = QCheckBox("Open service report on disconnect")
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
row1 = QHBoxLayout() self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn, self._active_port: PortInfo | None = None
self.connect_btn, self.reset_btn):
row1.addWidget(w) row1 = QHBoxLayout()
row1.addStretch(1) for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
row2 = QHBoxLayout() self.connect_btn, self.reset_btn):
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn, row1.addWidget(w)
self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service): row1.addStretch(1)
row2.addWidget(w) row2 = QHBoxLayout()
row2.addStretch(1) 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):
self.view = QPlainTextEdit(readOnly=True) row2.addWidget(w)
self.view.setMaximumBlockCount(MAX_LINES) row2.addStretch(1)
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas") self.view = QPlainTextEdit(readOnly=True)
mono.setStyleHint(QFont.StyleHint.Monospace) self.view.setMaximumBlockCount(MAX_LINES)
mono.setPointSize(10) self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.view.setFont(mono) mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }") mono.setStyleHint(QFont.StyleHint.Monospace)
mono.setPointSize(10)
self.diag = DiagnosticsPanel() self.view.setFont(mono)
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload) self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
self.side_tabs = QTabWidget()
self.side_tabs.addTab(self.diag, "Diagnosis") self.diag = DiagnosticsPanel()
self.side_tabs.addTab(self.guide, "Guided troubleshooting") self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
split = QSplitter(Qt.Orientation.Horizontal) self.side_tabs = QTabWidget()
split.addWidget(self.view) self.side_tabs.addTab(self.diag, "Diagnosis")
split.addWidget(self.side_tabs) self.side_tabs.addTab(self.guide, "Guided troubleshooting")
split.setStretchFactor(0, 3) split = QSplitter(Qt.Orientation.Horizontal)
split.setStretchFactor(1, 2) split.addWidget(self.view)
split.setSizes([700, 480]) split.addWidget(self.side_tabs)
split.setStretchFactor(0, 3)
self.status = QLabel() split.setStretchFactor(1, 2)
layout = QVBoxLayout(self) split.setSizes([700, 480])
layout.addLayout(row1)
layout.addLayout(row2) self.status = QLabel()
layout.addWidget(split, 1) layout = QVBoxLayout(self)
layout.addWidget(self.status) layout.addLayout(row1)
layout.addLayout(row2)
# --- wiring layout.addWidget(split, 1)
self.refresh_btn.clicked.connect(self.refresh_ports) layout.addWidget(self.status)
self.all_ports.toggled.connect(self.refresh_ports)
self.connect_btn.clicked.connect(self._toggle_connect) # --- wiring
self.reset_btn.clicked.connect(self._manager.reset_board) self.refresh_btn.clicked.connect(self.refresh_ports)
self.clear_btn.clicked.connect(self.view.clear) self.all_ports.toggled.connect(self.refresh_ports)
self.save_btn.clicked.connect(self._save_view) self.connect_btn.clicked.connect(self._toggle_connect)
self.folder_btn.clicked.connect(self._open_folder) self.reset_btn.clicked.connect(self._manager.reset_board)
self.analyze_btn.clicked.connect(self._analyze_file) self.clear_btn.clicked.connect(self.view.clear)
self.copy_btn.clicked.connect(self._copy_report) self.save_btn.clicked.connect(self._save_view)
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False)) self.folder_btn.clicked.connect(self._open_folder)
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec()) self.analyze_btn.clicked.connect(self._analyze_file)
self.copy_btn.clicked.connect(self._copy_report)
# Report refresh is throttled; lines can arrive at >100/s. self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
self._diag_timer = QTimer(self, interval=400) self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
self._diag_timer.timeout.connect(self._refresh_diag)
self._diag_timer.start() # Report refresh is throttled; lines can arrive at >100/s.
self._diag_timer = QTimer(self, interval=400)
# Auto-detect a box being plugged in while idle. self._diag_timer.timeout.connect(self._refresh_diag)
self._poll = QTimer(self, interval=2000) self._diag_timer.start()
self._poll.timeout.connect(self._auto_refresh)
self._poll.start() # Auto-detect a box being plugged in while idle.
self._poll = QTimer(self, interval=2000)
self.refresh_ports() self._poll.timeout.connect(self._auto_refresh)
self._on_state(State.DISCONNECTED, "") self._poll.start()
# ---- ports ------------------------------------------------------- self.refresh_ports()
def refresh_ports(self) -> None: self._on_state(State.DISCONNECTED, "")
current = self.port_combo.currentData()
self._ports = list_serial_ports(include_all=self.all_ports.isChecked()) # ---- ports -------------------------------------------------------
self.port_combo.clear() def refresh_ports(self) -> None:
for p in self._ports: current = self.port_combo.currentData()
self.port_combo.addItem(p.label, p.device) self._ports = list_serial_ports(include_all=self.all_ports.isChecked())
if not self._ports: self.port_combo.clear()
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) for p in self._ports:
idx = self.port_combo.findData(current) self.port_combo.addItem(p.label, p.device)
self.port_combo.setCurrentIndex(max(idx, 0)) if not self._ports:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
def _auto_refresh(self) -> None: idx = self.port_combo.findData(current)
if self._manager.is_running: self.port_combo.setCurrentIndex(max(idx, 0))
return
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())] def _auto_refresh(self) -> None:
if devices != [p.device for p in self._ports]: if self._manager.is_running:
self.refresh_ports() return
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())]
def _selected_port(self) -> PortInfo | None: if devices != [p.device for p in self._ports]:
dev = self.port_combo.currentData() self.refresh_ports()
return next((p for p in self._ports if p.device == dev), None)
def _selected_port(self) -> PortInfo | None:
# ---- connection -------------------------------------------------- dev = self.port_combo.currentData()
def _toggle_connect(self) -> None: return next((p for p in self._ports if p.device == dev), None)
if self._manager.is_running:
# Session file is closed in _on_state(DISCONNECTED), after the # ---- connection --------------------------------------------------
# reader thread's final queued lines have been written. def _toggle_connect(self) -> None:
self._manager.disconnect() if self._manager.is_running:
return # Session file is closed in _on_state(DISCONNECTED), after the
port = self._selected_port() # reader thread's final queued lines have been written.
if port is None: self._manager.disconnect()
return return
self._connect(port) port = self._selected_port()
if port is None:
def _connect(self, port: PortInfo) -> None: return
self._counts = {"E": 0, "W": 0, "boots": 0} self._connect(port)
self._engine.reset()
self._runner.cancel() def _connect(self, port: PortInfo) -> None:
self._diag_dirty = True self._counts = {"E": 0, "W": 0, "boots": 0}
self._session.start(port.mac or port.device) self._engine.reset()
self._active_port = port self._runner.cancel()
self._manager.connect(port) self._diag_dirty = True
self._session.start(port.mac or port.device)
def matter_payload(self) -> SetupPayload: self._active_port = port
"""Pairing payload reported by this box (KOTA: matter), from its log, or the defaults.""" self._manager.connect(port)
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [],
self._engine.matter_qr, self._engine.matter_manual) def matter_payload(self) -> SetupPayload:
"""Pairing payload reported by this box (KOTA: matter), from its log, or the defaults."""
# ---- service system ------------------------------------------------- return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [],
def _service_base(self) -> str: self._engine.matter_qr, self._engine.matter_manual)
return str(self._settings.value("service/base", DEFAULT_BASE))
# ---- service system -------------------------------------------------
def _open_service_report(self, auto: bool) -> None: def _service_base(self) -> str:
mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "") return str(self._settings.value("service/base", DEFAULT_BASE))
if len(mac) != 12:
if not auto: def _open_service_report(self, auto: bool) -> None:
QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. " mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "")
"Connect to the box and press Reset & capture first.") if len(mac) != 12:
return if not auto:
url = report_url(mac, self._engine.report(), self._service_base()) QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. "
QDesktopServices.openUrl(QUrl(url)) "Connect to the box and press Reset & capture first.")
# Log that it happened, not the report itself (it is already in the session log). return
self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there") url = report_url(mac, self._engine.report(), self._service_base())
QDesktopServices.openUrl(QUrl(url))
# ---- hand-off with the flash tab ------------------------------------ # Log that it happened, not the report itself (it is already in the session log).
@property self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there")
def engine(self) -> DiagnosticsEngine:
return self._engine # ---- hand-off with the flash tab ------------------------------------
@property
def release_port(self) -> None: def engine(self) -> DiagnosticsEngine:
"""Stop monitoring so esptool can open the port exclusively.""" return self._engine
if self._manager.is_running:
self._no_auto_service = True # a flash hand-off is not the end of a session # ---- commands to the box (firmware usb_console) -----------------------
self._manager.disconnect() # joins the reader thread @property
QCoreApplication.processEvents() # deliver its queued final lines/state now def is_connected(self) -> bool:
self._no_auto_service = False return self._manager.state == State.CONNECTED
def connect_port(self, port: PortInfo, capture: bool = True) -> None: @property
"""Monitor `port`; with capture, restart the box once connected to record its boot.""" def active_port(self) -> PortInfo | None:
self.release_port() return self._active_port if self._manager.is_running else None
idx = self.port_combo.findData(port.device)
if idx < 0: def add_line_listener(self, callback) -> None:
self.port_combo.addItem(port.label, port.device) self._line_listeners.append(callback)
self._ports.append(port)
idx = self.port_combo.count() - 1 def send_command(self, command: str) -> bool:
self.port_combo.setCurrentIndex(idx) """Write one "kota ..." line to the box; False when not connected.
self._reset_on_connect = capture The session log gets the command without the Wi-Fi password."""
self._connect(port) if not self.is_connected:
return False
def _on_state(self, state: State, detail: str) -> None: self._manager.write((command + "\n").encode("ascii"))
running = state != State.DISCONNECTED if not command.startswith(("kota health", "kota ping")): # polled; would flood the log
if not running and self._manager.is_running: self._guide_log(f"sent to the box: {loggable(command)}")
return # stale event from a session that was already replaced return True
if state == State.CONNECTED and getattr(self, "_reset_on_connect", False):
self._reset_on_connect = False def connect_first_box(self) -> bool:
QTimer.singleShot(300, self._manager.reset_board) """Connect to the first sensorbox port found (for tabs that need the box)."""
if not running and self._session.path is not None: if self._manager.is_running:
self._session.write_report(self._engine.report().to_text()) return True
if (self.auto_service.isChecked() and self._engine.lines self.refresh_ports()
and not getattr(self, "_no_auto_service", False)): port = next((p for p in self._ports if p.is_sensorbox), None)
self._open_service_report(auto=True) if port is None:
self._session.stop() return False
self.connect_btn.setText("Disconnect" if running else "Connect") self.connect_port(port, capture=False)
self.reset_btn.setEnabled(state == State.CONNECTED) return True
self.port_combo.setEnabled(not running)
self.all_ports.setEnabled(not running) def release_port(self) -> None:
self.refresh_btn.setEnabled(not running) """Stop monitoring so esptool can open the port exclusively."""
# Offline analysis would mix with the live session; disconnect first. if self._manager.is_running:
self.analyze_btn.setEnabled(not running) self._no_auto_service = True # a flash hand-off is not the end of a session
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "") self._manager.disconnect() # joins the reader thread
self._update_status() QCoreApplication.processEvents() # deliver its queued final lines/state now
self._no_auto_service = False
def _update_status(self) -> None:
sess = f" | Session log: {self._session.path}" if self._session.path else "" def connect_port(self, port: PortInfo, capture: bool = True) -> None:
c = self._counts """Monitor `port`; with capture, restart the box once connected to record its boot."""
self.status.setText( self.release_port()
f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} " idx = self.port_combo.findData(port.device)
f"errors: {c['E']} warnings: {c['W']}{sess}" if idx < 0:
) self.port_combo.addItem(port.label, port.device)
self._ports.append(port)
# ---- lines ------------------------------------------------------- idx = self.port_combo.count() - 1
def _on_line(self, line: LogLine) -> None: self.port_combo.setCurrentIndex(idx)
self._session.write(line) self._reset_on_connect = capture
self._engine.feed(line) self._connect(port)
self._runner.feed(line)
self._diag_dirty = True def _on_state(self, state: State, detail: str) -> None:
if line.kind == LineKind.BOOT: running = state != State.DISCONNECTED
self._counts["boots"] += 1 if not running and self._manager.is_running:
if line.level in ("E", "W"): return # stale event from a session that was already replaced
self._counts[line.level] += 1 if state == State.CONNECTED and getattr(self, "_reset_on_connect", False):
self._update_status() self._reset_on_connect = False
QTimer.singleShot(300, self._manager.reset_board)
if self._visible(line): if not running and self._session.path is not None:
self._append(line) self._session.write_report(self._engine.report().to_text())
if (self.auto_service.isChecked() and self._engine.lines
def _visible(self, line: LogLine) -> bool: and not getattr(self, "_no_auto_service", False)):
allowed = LEVEL_FILTERS[self.filter_combo.currentText()] self._open_service_report(auto=True)
if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET): self._session.stop()
return True self.connect_btn.setText("Disconnect" if running else "Connect")
if allowed == "KOTA": self.reset_btn.setEnabled(state == State.CONNECTED)
return line.kind == LineKind.KOTA or line.level == "E" self.port_combo.setEnabled(not running)
return line.level in allowed self.all_ports.setEnabled(not running)
self.refresh_btn.setEnabled(not running)
def _append(self, line: LogLine) -> None: # Offline analysis would mix with the live session; disconnect first.
sb = self.view.verticalScrollBar() self.analyze_btn.setEnabled(not running)
at_bottom = sb.value() >= sb.maximum() - 4 self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
fmt = QTextCharFormat() self._update_status()
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"))) def _update_status(self) -> None:
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST) or line.record in ("boot", "status"): sess = f" | Session log: {self._session.path}" if self._session.path else ""
fmt.setFontWeight(QFont.Weight.Bold) c = self._counts
cur = QTextCursor(self.view.document()) self.status.setText(
cur.movePosition(QTextCursor.MoveOperation.End) f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} "
if not self.view.document().isEmpty(): f"errors: {c['E']} warnings: {c['W']}{sess}"
cur.insertBlock() )
cur.insertText(line.text, fmt)
if at_bottom: # ---- lines -------------------------------------------------------
sb.setValue(sb.maximum()) def _on_line(self, line: LogLine) -> None:
self._session.write(line)
# ---- diagnostics ------------------------------------------------- self._engine.feed(line)
def _refresh_diag(self) -> None: self._runner.feed(line)
if self._diag_dirty: for listener in self._line_listeners:
self._diag_dirty = False listener(line)
self.diag.show_report(self._engine.report()) self._diag_dirty = True
self.guide.refresh() # countdowns and timeouts need a steady tick if line.kind == LineKind.BOOT:
self._counts["boots"] += 1
# ---- guided troubleshooting hooks ------------------------------------ if line.level in ("E", "W"):
def _guide_action(self, action: str) -> bool: self._counts[line.level] += 1
if action == "reset" and self._manager.state == State.CONNECTED: self._update_status()
self._manager.reset_board()
return True if self._visible(line):
return False self._append(line)
def _guide_log(self, text: str) -> None: def _visible(self, line: LogLine) -> bool:
# Technician answers go into the session log and the log view. allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
line = host_event(text) if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET):
self._session.write(line) return True
self._append(line) if allowed == "KOTA":
self._diag_dirty = True return line.kind == LineKind.KOTA or line.level == "E"
return line.level in allowed
def _copy_report(self) -> None:
QGuiApplication.clipboard().setText(self._engine.report().to_text()) def _append(self, line: LogLine) -> None:
sb = self.view.verticalScrollBar()
def _analyze_file(self) -> None: at_bottom = sb.value() >= sb.maximum() - 4
path, _ = QFileDialog.getOpenFileName( fmt = QTextCharFormat()
self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)") colour = COLORS.get(line.kind) if line.kind == LineKind.KOTA and line.level == "I" else None
if not path: fmt.setForeground(QColor(colour or COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
return if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST) or line.record in ("boot", "status"):
lines = read_log_file(Path(path)) fmt.setFontWeight(QFont.Weight.Bold)
self.view.clear() cur = QTextCursor(self.view.document())
self._engine.reset() cur.movePosition(QTextCursor.MoveOperation.End)
self._runner.cancel() if not self.view.document().isEmpty():
self._counts = {"E": 0, "W": 0, "boots": 0} cur.insertBlock()
for line in lines: cur.insertText(line.text, fmt)
self._engine.feed(line) if at_bottom:
if line.kind == LineKind.BOOT: sb.setValue(sb.maximum())
self._counts["boots"] += 1
if line.level in ("E", "W"): # ---- diagnostics -------------------------------------------------
self._counts[line.level] += 1 def _refresh_diag(self) -> None:
self._append(line) if self._diag_dirty:
self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)" self._diag_dirty = False
self._update_status() self.diag.show_report(self._engine.report())
self._diag_dirty = True self.guide.refresh() # countdowns and timeouts need a steady tick
self._refresh_diag()
# ---- guided troubleshooting hooks ------------------------------------
# ---- misc -------------------------------------------------------- def _guide_action(self, action: str) -> bool:
def _save_view(self) -> None: if action == "reset" and self._manager.state == State.CONNECTED:
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)") self._manager.reset_board()
if path: return True
with open(path, "w", encoding="utf-8") as fh: return False
fh.write(self.view.toPlainText())
def _guide_log(self, text: str) -> None:
def _open_folder(self) -> None: # Technician answers go into the session log and the log view.
self._session.root.mkdir(parents=True, exist_ok=True) line = host_event(text)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root))) self._session.write(line)
self._append(line)
def shutdown(self) -> None: self._diag_dirty = True
self._manager.disconnect()
self._session.stop() def _copy_report(self) -> None:
QGuiApplication.clipboard().setText(self._engine.report().to_text())
class MainWindow(QMainWindow): def _analyze_file(self) -> None:
def __init__(self): path, _ = QFileDialog.getOpenFileName(
super().__init__() self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)")
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}") if not path:
self.resize(1200, 800) return
tabs = QTabWidget() lines = read_log_file(Path(path))
guide = QTextBrowser(openExternalLinks=True) self.view.clear()
guide.setHtml(cheatsheet_html()) self._engine.reset()
self.serial_tab = SerialTab() self._runner.cancel()
self.flash_tab = FlashTab(self.serial_tab) self._counts = {"E": 0, "W": 0, "boots": 0}
tabs.addTab(guide, "Quick start") for line in lines:
tabs.addTab(self.serial_tab, "Serial monitor") self._engine.feed(line)
tabs.addTab(self.flash_tab, "Flash firmware") if line.kind == LineKind.BOOT:
self.sd_tab = SdTab(self.serial_tab) self._counts["boots"] += 1
tabs.addTab(self.sd_tab, "SD card") if line.level in ("E", "W"):
self.setCentralWidget(tabs) self._counts[line.level] += 1
settings_menu = self.menuBar().addMenu("Settings") self._append(line)
settings_menu.addAction("Service system address...", self._edit_service_base) self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)"
self._update_status()
def _edit_service_base(self) -> None: self._diag_dirty = True
st = self.serial_tab self._refresh_diag()
text, ok = QInputDialog.getText(
self, "Service system address", # ---- misc --------------------------------------------------------
"Address of the KOTA service system (the box MAC is added at the end):", def _save_view(self) -> None:
text=st._service_base()) path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
if ok and text.strip().startswith(("https://", "http://")): if path:
st._settings.setValue("service/base", text.strip()) with open(path, "w", encoding="utf-8") as fh:
elif ok: fh.write(self.view.toPlainText())
QMessageBox.warning(self, "Service system address", "The address must start with https://")
def _open_folder(self) -> None:
def closeEvent(self, event): # noqa: N802 (Qt naming) self._session.root.mkdir(parents=True, exist_ok=True)
self.serial_tab.shutdown() QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root)))
super().closeEvent(event)
def shutdown(self) -> None:
self._manager.disconnect()
self._session.stop()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}")
self.resize(1200, 800)
tabs = QTabWidget()
guide = QTextBrowser(openExternalLinks=True)
guide.setHtml(cheatsheet_html())
self.serial_tab = SerialTab()
self.flash_tab = FlashTab(self.serial_tab)
tabs.addTab(guide, "Quick start")
tabs.addTab(self.serial_tab, "Serial monitor")
tabs.addTab(self.flash_tab, "Flash firmware")
self.wifi_tab = WifiTab(self.serial_tab)
tabs.addTab(self.wifi_tab, "Wi-Fi (USB)")
self.sd_tab = SdTab(self.serial_tab)
tabs.addTab(self.sd_tab, "SD card")
self.setCentralWidget(tabs)
settings_menu = self.menuBar().addMenu("Settings")
settings_menu.addAction("Service system address...", self._edit_service_base)
def _edit_service_base(self) -> None:
st = self.serial_tab
text, ok = QInputDialog.getText(
self, "Service system address",
"Address of the KOTA service system (the box MAC is added at the end):",
text=st._service_base())
if ok and text.strip().startswith(("https://", "http://")):
st._settings.setValue("service/base", text.strip())
elif ok:
QMessageBox.warning(self, "Service system address", "The address must start with https://")
def closeEvent(self, event): # noqa: N802 (Qt naming)
self.serial_tab.shutdown()
super().closeEvent(event)
+76 -76
View File
@@ -1,76 +1,76 @@
"""Matter pairing QR code on screen, so the technician can scan it from the laptop.""" """Matter pairing QR code on screen, so the technician can scan it from the laptop."""
from __future__ import annotations from __future__ import annotations
import segno import segno
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
from ..matter.payload import SetupPayload from ..matter.payload import SetupPayload
def qr_pixmap(text: str, size: int = 260) -> QPixmap: def qr_pixmap(text: str, size: int = 260) -> QPixmap:
"""Black-on-white QR with the quiet zone phones need (4 modules).""" """Black-on-white QR with the quiet zone phones need (4 modules)."""
qr = segno.make(text, error="m") qr = segno.make(text, error="m")
matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)] matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)]
n = len(matrix) n = len(matrix)
module = max(1, size // n) module = max(1, size // n)
img = QImage(n * module, n * module, QImage.Format.Format_RGB32) img = QImage(n * module, n * module, QImage.Format.Format_RGB32)
img.fill(QColor("white")) img.fill(QColor("white"))
p = QPainter(img) p = QPainter(img)
p.setPen(Qt.PenStyle.NoPen) p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor("black")) p.setBrush(QColor("black"))
for y, row in enumerate(matrix): for y, row in enumerate(matrix):
for x, dark in enumerate(row): for x, dark in enumerate(row):
if dark: if dark:
p.drawRect(x * module, y * module, module, module) p.drawRect(x * module, y * module, module, module)
p.end() p.end()
return QPixmap.fromImage(img) return QPixmap.fromImage(img)
class MatterQrWidget(QWidget): class MatterQrWidget(QWidget):
def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None): def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None):
super().__init__(parent) super().__init__(parent)
self.image = QLabel() self.image = QLabel()
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code = QLabel() self.code = QLabel()
self.code.setAlignment(Qt.AlignmentFlag.AlignCenter) self.code.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
f = self.code.font() f = self.code.font()
f.setPointSize(f.pointSize() + 4) f.setPointSize(f.pointSize() + 4)
f.setBold(True) f.setBold(True)
self.code.setFont(f) self.code.setFont(f)
self.note = QLabel(wordWrap=True) self.note = QLabel(wordWrap=True)
self.note.setAlignment(Qt.AlignmentFlag.AlignCenter) self.note.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.note.setStyleSheet("color:#555") self.note.setStyleSheet("color:#555")
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.addWidget(self.image) lay.addWidget(self.image)
lay.addWidget(self.code) lay.addWidget(self.code)
lay.addWidget(self.note) lay.addWidget(self.note)
self._size = size self._size = size
self.set_payload(payload) self.set_payload(payload)
def set_payload(self, payload: SetupPayload) -> None: def set_payload(self, payload: SetupPayload) -> None:
self.payload = payload self.payload = payload
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size)) self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
self.code.setText(f"Manual code: {payload.manual_code_pretty()}") self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
if payload.from_box: if payload.from_box:
src = "QR and manual code exactly as reported by the box (KOTA: matter line)" src = "QR and manual code exactly as reported by the box (KOTA: matter line)"
else: else:
src = f"Discriminator {payload.discriminator} " + ( src = f"Discriminator {payload.discriminator} " + (
"(from this box's log)" if payload.from_log else "(from this box's log)" if payload.from_log else
"(standard code of the v1.0 firmware - connect the box to confirm)") "(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. " self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. "
f"{src}. Same as the sticker on the box.") f"{src}. Same as the sticker on the box.")
class MatterQrDialog(QDialog): class MatterQrDialog(QDialog):
def __init__(self, payload: SetupPayload, parent: QWidget | None = None): def __init__(self, payload: SetupPayload, parent: QWidget | None = None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Matter pairing code") self.setWindowTitle("Matter pairing code")
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.addWidget(MatterQrWidget(payload, 320)) lay.addWidget(MatterQrWidget(payload, 320))
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
lay.addWidget(buttons) lay.addWidget(buttons)
+207
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.7.0" version = "0.9.0"
description = "KOTA sensorbox technician diagnostic tool (demo)" description = "KOTA sensorbox technician diagnostic tool (demo)"
requires-python = ">=3.11,<3.14" requires-python = ">=3.11,<3.14"
dependencies = [ dependencies = [
+4 -2
View File
@@ -2,8 +2,10 @@
# Double-click in Finder, or run: bash run_macos.command # Double-click in Finder, or run: bash run_macos.command
cd "$(dirname "$0")" || exit 1 cd "$(dirname "$0")" || exit 1
# Keep the virtual environment out of the (Google Drive) tool folder. # If Finder refuses to open this file (Google Drive drops the executable bit), run it once as:
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.7" # 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" export PATH="$HOME/.local/bin:$PATH"
if ! command -v uv >/dev/null 2>&1; then if ! command -v uv >/dev/null 2>&1; then
+2 -2
View File
@@ -2,8 +2,8 @@
setlocal setlocal
cd /d "%~dp0" cd /d "%~dp0"
rem Keep the virtual environment out of the (Google Drive) tool folder. rem One shared Python environment for every version (update_windows.bat keeps it current).
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.7" set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
set "PATH=%USERPROFILE%\.local\bin;%PATH%" set "PATH=%USERPROFILE%\.local\bin;%PATH%"
where uv >nul 2>nul where uv >nul 2>nul
@@ -0,0 +1,973 @@
--- connected to COM14 ---
W (9426) wifi:Haven't to connect to a suitable AP now!
--- reset issued by tool ---
ESP-ROM:esp32s3-20210327
Build:Mar 27 2021
rst:0x15 (USB_UART_CHIP_RESET),boot:0xb (SPI_FAST_FLASH_BOOT)
Saved PC:0x4037f366
SPIWP:0xee
mode:DIO, clock div:1
load:0x3fce2820,len:0x14f0
load:0x403c8700,len:0xd24
load:0x403cb700,len:0x2f48
entry 0x403c8924
I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader
I (24) boot: compile time Sep 10 2026 13:24:39
I (25) boot: Multicore bootloader
I (25) boot: chip revision: v0.2
I (28) boot: efuse block revision: v1.4
I (32) boot.esp32s3: Boot SPI Speed : 80MHz
I (35) boot.esp32s3: SPI Mode : DIO
I (39) boot.esp32s3: SPI Flash Size : 8MB
I (43) boot: Enabling RNG early entropy source...
I (48) boot: Partition Table:
I (50) boot: ## Label Usage Type ST Offset Length
I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000
I (63) boot: 1 nvs WiFi data 01 02 00010000 00020000
I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000
I (76) boot: 3 otadata OTA data 01 00 00031000 00002000
I (82) boot: 4 phy_init RF data 01 01 00033000 00001000
I (89) boot: 5 ota_0 OTA app 00 10 00040000 00300000
I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000
I (102) boot: 7 fctry WiFi data 01 02 00640000 00006000
I (109) boot: End of partition table
I (112) esp_image: segment 0: paddr=00040020 vaddr=3c190020 size=67420h (422944) map
I (194) esp_image: segment 1: paddr=000a7448 vaddr=3fc9c700 size=05d00h ( 23808) load
I (200) esp_image: segment 2: paddr=000ad150 vaddr=40374000 size=02ec8h ( 11976) load
I (203) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=187370h (1602416) map
I (488) esp_image: segment 4: paddr=00237398 vaddr=40376ec8 size=1580ch ( 88076) load
I (507) esp_image: segment 5: paddr=0024cbac vaddr=5000002c size=00020h ( 32) load
I (517) boot: Loaded app from partition at offset 0x40000
I (518) boot: Disabling RNG early entropy source...
I (528) octal_psram: vendor id : 0x0d (AP)
I (528) octal_psram: dev id : 0x02 (generation 3)
I (529) octal_psram: density : 0x03 (64 Mbit)
I (530) octal_psram: good-die : 0x01 (Pass)
I (535) octal_psram: Latency : 0x01 (Fixed)
I (539) octal_psram: VCC : 0x01 (3V)
I (543) octal_psram: SRF : 0x01 (Fast Refresh)
I (548) octal_psram: BurstType : 0x01 (Hybrid Wrap)
I (553) octal_psram: BurstLen : 0x01 (32 Byte)
I (557) octal_psram: Readlatency : 0x02 (10 cycles@Fixed)
I (562) octal_psram: DriveStrength: 0x00 (1/1)
I (567) esp_psram: Found 8MB PSRAM device
I (570) esp_psram: Speed: 40MHz
I (573) cpu_start: Multicore app
I (1307) esp_psram: SPI SRAM memory test OK
I (1315) cpu_start: GPIO 44 and 43 are used as console UART I/O pins
I (1316) cpu_start: Pro cpu start user code
I (1316) cpu_start: cpu freq: 160000000 Hz
I (1318) app_init: Application information:
I (1322) app_init: Project name: kota-sensorboard
I (1327) app_init: App version: 1.0.16
I (1331) app_init: Compile time: Sep 28 2026 15:07:57
I (1336) app_init: ELF file SHA256: 3ae273ec1...
I (1340) app_init: ESP-IDF: v5.5.3-dirty
I (1345) efuse_init: Min chip rev: v0.0
I (1348) efuse_init: Max chip rev: v0.99
I (1353) efuse_init: Chip rev: v0.2
I (1357) heap_init: Initializing. RAM available for dynamic allocation:
I (1363) heap_init: At 3FCB4AD0 len 00034C40 (211 KiB): RAM
I (1368) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM
I (1373) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM
I (1379) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM
I (1384) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator
I (1392) spi_flash: detected chip: generic
I (1394) spi_flash: flash io: dio
I (1400) sleep_gpio: Configure to isolate all GPIO pins in sleep state
I (1404) sleep_gpio: Enable automatic switching of GPIO sleep configuration
I (1419) main_task: Started on CPU0
I (1422) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations
I (1430) main_task: Calling app_main()
E (1433) ID: [E-MAIN-04] Device MAC address: 14:C1:9F:57:72:48
I (1475) KOTA: led color=green pattern=solid
I (1676) I2C: Creating I2C mutex
I (1694) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688
I (1696) OTA_CLIENT: OTA token loaded from NVS: <REDACTED>
I (1698) OTA_CLIENT: Device is not in selfcheck mode
I (1702) OTA_CLIENT: Update metadata loaded from NVS: version=15, status=2
I (1707) OTA_CLIENT: Device is in factory state
I (1711) SPI: Creating SPI mutex
I (1755) SD_CARD: SD card mounted successfully
I (1756) SD_CARD: /sdcard exists, SD card initialized successfully
I (1758) INFLUX_BUFFER: Buffer file does not exist, returning count of 0
I (1763) KOTA: sd type=SDHC size=29.2GB fs=FAT32 free=29.1GB env_keys=INFLUX_TOKEN,INFLUX_URL buffer_lines=0
I (1774) WIFI_CREDENTIALS: SSID/PASSWORD not set in .env; Matter will be used for WiFi provisioning
I (1785) MAIN: Matter node created successfully
W (1787) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
W (1793) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
W (1802) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
W (1811) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
W (1819) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
W (1828) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
W (1837) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
W (1846) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
W (1854) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
W (1863) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
W (1872) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
W (1881) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
W (1890) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
W (1898) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
W (1907) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
W (1916) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
W (1925) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
W (1933) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
I (1944) pp: pp rom version: e7ae62f
I (1945) net80211: net80211 rom version: e7ae62f
I (1951) wifi:wifi driver task: 3fcc9610, prio:23, stack:6656, core=0
I (1969) wifi:wifi firmware version: 4df78f2
I (1970) wifi:wifi certification version: v7.0
I (1971) wifi:config NVS flash: enabled
I (1971) wifi:config nano formatting: disabled
I (1975) wifi:Init data frame dynamic rx buffer num: 32
I (1980) wifi:Init static rx mgmt buffer num: 5
I (1984) wifi:Init management short buffer num: 32
I (1988) wifi:Init dynamic tx buffer num: 32
I (1992) wifi:Init static tx FG buffer num: 2
I (1997) wifi:Init static rx buffer size: 1600
I (2001) wifi:Init static rx buffer num: 10
I (2004) wifi:Init dynamic rx buffer num: 32
I (2009) wifi_init: rx ba win: 6
I (2011) wifi_init: accept mbox: 6
I (2014) wifi_init: tcpip mbox: 32
I (2018) wifi_init: udp mbox: 6
I (2020) wifi_init: tcp mbox: 6
I (2023) wifi_init: tcp tx win: 5760
I (2027) wifi_init: tcp rx win: 5760
I (2030) wifi_init: tcp mss: 1440
I (2045) chip[DL]: NVS set: chip-counters/reboot-count = 13 (0xD)
I (2048) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC)
I (2050) BLE_INIT: BT controller compile version [1bb2f50]
I (2053) BLE_INIT: Using main XTAL as clock source
I (2058) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1
I (2067) BLE_INIT: Bluetooth MAC: 14:c1:9f:57:72:4a
I (2072) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10
I (2119) NimBLE: GAP procedure initiated: stop advertising.
I (2122) CHIP[DL]: BLE host-controller synced
I (2623) chip[DL]: Starting ESP WiFi layer
I (2626) wifi:mode : sta (14:c1:9f:57:72:48)
I (2627) wifi:enable tsf
W (2628) wifi:Haven't to connect to a suitable AP now!
I (2629) chip[DL]:
I (2629) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43Attempting to connect WiFi station interface
I (2641) chip[DL]: WiFi station state change: NotConnected -> Connecting
I (2642) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2
I (2647) chip[DL]:
Done driving station state, nothing else to do...
I (2659) chip[SVR]: Initializing subscription resumption storage...
I (2666) chip[SVR]: Server initializing...
I (2669) chip[TS]: Last Known Good Time: 2026-09-29T14:59:23
I (2674) chip[DMG]: AccessControl: initializing
I (2678) chip[DMG]: Examples::AccessControlDelegate::Init
I (2683) chip[DMG]: AccessControl: setting
I (2687) chip[DMG]: DefaultAclStorage: initializing
I (2691) chip[DMG]: DefaultAclStorage: 0 entries loaded
I (2696) chip[SVR]: WARNING: mTestEventTriggerDelegate is null
I (2715) chip[ZCL]: Using ZAP configuration...
I (2716) esp_matter_cluster: Cluster plugin init common callback
I (2718) chip[DMG]: AccessControlCluster: initializing
I (2720) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported
I (2730) chip[DIS]: Updating services using commissioning mode 1
I (2733) chip[DIS]: CHIP minimal mDNS started advertising.
I (2739) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
I (2749) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: 995B80B34BC8AAD9.
I (2758) chip[DIS]: mDNS service published: _matterc._udp
I (2763) chip[IN]: CASE Server enabling CASE session setups
I (2769) chip[SVR]: Joining Multicast groups
I (2772) chip[SVR]: Server Listening...
I (2778) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 **********
I (2788) data_model: Dynamic endpoint 0 added
E (2790) data_model: Cluster cannot be NULL.
I (2795) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 **********
I (2806) data_model: Dynamic endpoint 1 added
E (2809) data_model: Cluster cannot be NULL.
I (2815) chip[DL]:
I (2815) MAIN: ESP-Matter started successfully
WIFI_EVENT_STA_START
W (2821) wifi:Haven't to connect to a suitable AP now!
I (2826) chip[DL]: Attempting to connect WiFi station interface
E (2832) wifi:sta is connecting, return error
E (2836) chip[DL]: esp_wifi_connect() failed: ESP_ERR_WIFI_CONN
I (2842) KOTA: boot fw=1.0.16 build=3ae273ec hw=2 mac=14C19F577248 reset=USB_UART_CHIP_RESET mode=matter commissioned=0 last_restart=none
I (2843) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable)
I (2861) NimBLE: GAP procedure initiated: advertise;
I (2865) NimBLE: disc_mode=2
I (2867) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40
I (2877) NimBLE:
I (2880) chip[DL]: CHIPoBLE advertising started
I (2883) MAIN: Commissioning window opened
I (2888) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332
I (2896) KOTA: led color=cyan pattern=breathe period=3000ms
I (2901) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332
W (2909) wifi:Haven't to connect to a suitable AP now!
I (2908) KOTA: health up=1s status=OK wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=0 heap=117491 psram=7.9M last_upload=never ota=registered
W (3401) wifi:Haven't to connect to a suitable AP now!
I (3910) KOTA: rtc running=0 time=2000-01-01T00:00:00
W (4402) wifi:Haven't to connect to a suitable AP now!
W (5403) wifi:Haven't to connect to a suitable AP now!
W (6404) wifi:Haven't to connect to a suitable AP now!
I (7219) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (7222) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (7223) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (7226) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (7233) wifi:Haven't to connect to a suitable AP now!
E (7238) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (7248) wifi:Haven't to connect to a suitable AP now!
I (7252) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (7259) wifi:Haven't to connect to a suitable AP now!
E (7264) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (7273) chip[DL]: Next WiFi station reconnect in 100 ms
I (7278) chip[DL]: Done driving station state, nothing else to do...
W (7379) wifi:Haven't to connect to a suitable AP now!
I (7380) chip[DL]: Attempting to connect WiFi station interface
I (7383) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (7386) wifi:Haven't to connect to a suitable AP now!
E (7391) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (7400) chip[DL]: Done driving station state, nothing else to do...
W (7405) wifi:Haven't to connect to a suitable AP now!
W (8411) wifi:Haven't to connect to a suitable AP now!
W (9415) wifi:Haven't to connect to a suitable AP now!
W (10417) wifi:Haven't to connect to a suitable AP now!
W (11419) wifi:Haven't to connect to a suitable AP now!
I (12201) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (12204) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (12205) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (12208) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (12215) wifi:Haven't to connect to a suitable AP now!
E (12220) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (12230) wifi:Haven't to connect to a suitable AP now!
I (12234) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (12242) wifi:Haven't to connect to a suitable AP now!
E (12247) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (12256) chip[DL]: Next WiFi station reconnect in 100 ms
I (12261) chip[DL]: Done driving station state, nothing else to do...
W (12361) wifi:Haven't to connect to a suitable AP now!
I (12362) chip[DL]: Attempting to connect WiFi station interface
I (12366) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (12368) wifi:Haven't to connect to a suitable AP now!
E (12373) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (12383) chip[DL]: Done driving station state, nothing else to do...
W (12427) wifi:Haven't to connect to a suitable AP now!
W (13428) wifi:Haven't to connect to a suitable AP now!
I (13912) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
I (13917) INFLUX_BUFFER: Buffer file does not exist, returning count of 0
W (13920) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
I (13943) BME688: Delaying for 10000 microseconds
W (14430) wifi:Haven't to connect to a suitable AP now!
I (14563) KOTA: mic enabled=1 (GPIO3=0, expected 0)
I (14566) Flicker: Flicker sensor initialized on pin 14
I (14567) temperature_sensor: Range [20°C ~ 100°C], error < 2°C
I (14569) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data
W (14573) wifi:
I (14569) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data
Haven't to connect to a suitable AP now!
I (14570) SEN66: Creating sensor mutex
I (14572) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms
I (14600) Flicker: Starting to collect samples in gather task
I (14585) DeviceStatus: Active tasks: 27
I (14588) SEN66: Waking up sensor by setting power GPIO to enabled level...
I (14570) INFLUX_BUFFER: Buffer file does not exist, returning empty buffer
I (14622) INFLUX_BUFFER: Buffer is empty, nothing to flush
I (14613) DeviceStatus: Task list:
gather_sensor_t R 1 1920 25
gather_sensor_t X 1 1368 23
IDLE1 R 0 700 6
IDLE0 R 0 692 5
gather_sensor_t B 1 2556 22
reset_task B 0 3356 10
ds1307_update B 1 776 18
tiT B 18 2420 12
gather_task B 1 1916 20
gather_task B 1 14288 21
ota_task B 1 7516 19
CHIP B 1 3700 14
health B 1 728 17
Tmr Svc B 1 1364 7
led_task B 5 868 8
nimble_host B 21 1348 16
ipc0 S 24 372 1
ipc1 S 24 524 2
sys_evt B 20 2676 11
gather_sensor_t B 1 3180 26
esp_timer S 22 3044 3
gather_sensor_t B 1 2324 24
main B 1 892 4
gather_sensor_t S 1 1480 27
wifi B 23 3796 13
btController B 23 1864 15
flicker_gather_ S 24 1988 28
usb_console B 1 2840 9
W (14702) DeviceStatus: RTC is uninitialized, using current time as timestamp
W (15433) wifi:Haven't to connect to a suitable AP now!
I (15630) Flicker: Finished collecting samples, notifying main task
I (15632) Flicker: Collected 1024 samples for dominant frequency analysis
I (15639) SENSOR_GATHERER: Sensor should now be asleep
W (15650) AS7341: Reading spectral data from AS7341
I (15659) BME688: Delaying for 199517 microseconds
W (15710) DeviceStatus: RTC is uninitialized, using current time as timestamp
W (15958) AS7341: Reading basic counts data from AS7341
W (15974) AS7341: Reading flicker detection state from AS7341
W (16194) as7341: FD Status Register: 0x20 (0b00100000)
I (16201) AS7341: Updating Matter attributes with AS7341 data: 10474
I (16202) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 **********
I (16208) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 10474 **********
E (16219) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
E (16229) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
W (16238) AS7341: RTC is uninitialized, using current time as timestamp
I (16241) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null **********
I (16256) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1021 **********
E (16267) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
E (16276) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
W (16286) BME688: RTC is uninitialized, using current time as timestamp
W (16434) wifi:Haven't to connect to a suitable AP now!
W (16711) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (17183) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (17186) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (17187) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (17190) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (17198) wifi:Haven't to connect to a suitable AP now!
E (17203) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (17212) wifi:Haven't to connect to a suitable AP now!
I (17217) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (17224) wifi:Haven't to connect to a suitable AP now!
E (17229) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (17239) chip[DL]: Next WiFi station reconnect in 100 ms
I (17243) chip[DL]: Done driving station state, nothing else to do...
W (17244) AS7341: RTC is uninitialized, using current time as timestamp
W (17291) BME688: RTC is uninitialized, using current time as timestamp
W (17344) wifi:Haven't to connect to a suitable AP now!
I (17346) chip[DL]: Attempting to connect WiFi station interface
I (17349) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (17351) wifi:Haven't to connect to a suitable AP now!
E (17356) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (17366) chip[DL]: Done driving station state, nothing else to do...
W (17437) wifi:Haven't to connect to a suitable AP now!
W (17712) DeviceStatus: RTC is uninitialized, using current time as timestamp
W (18256) AS7341: RTC is uninitialized, using current time as timestamp
W (18292) BME688: RTC is uninitialized, using current time as timestamp
W (18439) wifi:Haven't to connect to a suitable AP now!
W (18713) DeviceStatus: RTC is uninitialized, using current time as timestamp
W (19257) AS7341: RTC is uninitialized, using current time as timestamp
W (19293) BME688: RTC is uninitialized, using current time as timestamp
W (19440) wifi:Haven't to connect to a suitable AP now!
I (19615) SEN66: Sent start measurement command, waiting for sensor to be ready...
W (19714) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (19928) KOTA: cmd ping ok=1 fw=1.0.16 api=1
I (19933) KOTA: cmd wifi_get ok=1 ssid=- password_set=0 mode=matter
W (20258) AS7341: RTC is uninitialized, using current time as timestamp
W (20294) BME688: RTC is uninitialized, using current time as timestamp
W (20441) wifi:Haven't to connect to a suitable AP now!
W (20715) DeviceStatus: RTC is uninitialized, using current time as timestamp
W (21259) AS7341: RTC is uninitialized, using current time as timestamp
W (21295) BME688: RTC is uninitialized, using current time as timestamp
W (21442) wifi:Haven't to connect to a suitable AP now!
W (21716) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (22166) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (22169) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (22170) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (22173) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (22181) wifi:Haven't to connect to a suitable AP now!
E (22186) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (22196) wifi:Haven't to connect to a suitable AP now!
I (22200) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (22207) wifi:Haven't to connect to a suitable AP now!
E (22212) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (22222) chip[DL]: Next WiFi station reconnect in 100 ms
I (22226) chip[DL]: Done driving station state, nothing else to do...
W (22260) AS7341: RTC is uninitialized, using current time as timestamp
W (22296) BME688: RTC is uninitialized, using current time as timestamp
W (22327) wifi:Haven't to connect to a suitable AP now!
I (22328) chip[DL]: Attempting to connect WiFi station interface
I (22332) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (22334) wifi:Haven't to connect to a suitable AP now!
E (22340) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (22349) chip[DL]: Done driving station state, nothing else to do...
W (22446) wifi:Haven't to connect to a suitable AP now!
I (22574) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (22717) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (23075) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (23261) AS7341: RTC is uninitialized, using current time as timestamp
W (23297) BME688: RTC is uninitialized, using current time as timestamp
W (23447) wifi:Haven't to connect to a suitable AP now!
I (23576) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (23718) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (24077) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (24262) AS7341: RTC is uninitialized, using current time as timestamp
W (24299) BME688: RTC is uninitialized, using current time as timestamp
W (24448) wifi:Haven't to connect to a suitable AP now!
I (24570) OTA_CLIENT: HTTP client handle: 0x3fcf02e4
I (24578) SENSOR_GATHERER: 2/2 tasks still running, waiting...
I (24710) MICROPHONE: Read 600000 bytes in 9940 ms
I (24712) MICROPHONE: Read 600000 bytes of audio data
W (24719) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (25079) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (25264) AS7341: RTC is uninitialized, using current time as timestamp
W (25300) BME688: RTC is uninitialized, using current time as timestamp
W (25449) wifi:Haven't to connect to a suitable AP now!
I (25580) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (25720) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (26081) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (26265) AS7341: RTC is uninitialized, using current time as timestamp
W (26301) BME688: RTC is uninitialized, using current time as timestamp
W (26450) wifi:Haven't to connect to a suitable AP now!
I (26582) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (26721) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (27083) SENSOR_GATHERER: 2/2 tasks still running, waiting...
I (27150) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (27152) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (27154) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (27157) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (27164) wifi:Haven't to connect to a suitable AP now!
E (27169) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (27179) wifi:Haven't to connect to a suitable AP now!
I (27183) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (27190) wifi:Haven't to connect to a suitable AP now!
E (27196) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (27205) chip[DL]: Next WiFi station reconnect in 100 ms
I (27209) chip[DL]: Done driving station state, nothing else to do...
W (27266) AS7341: RTC is uninitialized, using current time as timestamp
W (27302) BME688: RTC is uninitialized, using current time as timestamp
W (27310) wifi:Haven't to connect to a suitable AP now!
I (27311) chip[DL]: Attempting to connect WiFi station interface
I (27315) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (27318) wifi:Haven't to connect to a suitable AP now!
E (27323) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (27332) chip[DL]: Done driving station state, nothing else to do...
W (27456) wifi:Haven't to connect to a suitable AP now!
I (27584) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (27722) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (28085) SENSOR_GATHERER: 2/2 tasks still running, waiting...
I (28160) MICROPHONE: Raw block stats: mean=-17.8 min=-146018 max=134018
I (28161) MICROPHONE: Sum SQ: 0.578925, RMS pressure: 0.001701, Normalized: 0.001701, dB peak: -55.38
W (28166) SEN66: RTC is uninitialized, using current time as timestamp
W (28267) AS7341: RTC is uninitialized, using current time as timestamp
W (28303) BME688: RTC is uninitialized, using current time as timestamp
W (28457) wifi:Haven't to connect to a suitable AP now!
I (28586) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (28723) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (29087) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (29172) SEN66: RTC is uninitialized, using current time as timestamp
W (29268) AS7341: RTC is uninitialized, using current time as timestamp
W (29304) BME688: RTC is uninitialized, using current time as timestamp
W (29458) wifi:Haven't to connect to a suitable AP now!
I (29588) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (29724) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (30089) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (30173) SEN66: RTC is uninitialized, using current time as timestamp
W (30269) AS7341: RTC is uninitialized, using current time as timestamp
W (30305) BME688: RTC is uninitialized, using current time as timestamp
W (30459) wifi:Haven't to connect to a suitable AP now!
I (30590) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (30725) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (31091) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (31174) SEN66: RTC is uninitialized, using current time as timestamp
W (31270) AS7341: RTC is uninitialized, using current time as timestamp
W (31306) BME688: RTC is uninitialized, using current time as timestamp
W (31460) wifi:Haven't to connect to a suitable AP now!
E (31572) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0
E (31573) esp-tls: Failed to open new connection
E (31575) transport_base: Failed to open a new connection
E (31580) HTTP_CLIENT: Connection failed, sock < 0
E (31585) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:355
I (31592) SENSOR_GATHERER: 2/2 tasks still running, waiting...
E (31593) OTA_CLIENT: [E-OTA-13] Failed to check for updates: ESP_ERR_HTTP_CONNECT
I (31606) KOTA: status OK -> CONNECTION_ERROR reason=ota_check_failed
W (31726) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (32098) SENSOR_GATHERER: 2/2 tasks still running, waiting...
I (32133) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (32135) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (32137) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (32140) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (32147) wifi:Haven't to connect to a suitable AP now!
E (32152) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (32162) wifi:Haven't to connect to a suitable AP now!
I (32166) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (32173) wifi:Haven't to connect to a suitable AP now!
W (32175) SEN66: RTC is uninitialized, using current time as timestamp
E (32179) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (32194) chip[DL]: Next WiFi station reconnect in 100 ms
I (32199) chip[DL]: Done driving station state, nothing else to do...
W (32271) AS7341: RTC is uninitialized, using current time as timestamp
W (32300) wifi:Haven't to connect to a suitable AP now!
I (32301) chip[DL]: Attempting to connect WiFi station interface
I (32305) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (32307) BME688: RTC is uninitialized, using current time as timestamp
W (32307) wifi:Haven't to connect to a suitable AP now!
E (32319) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (32328) chip[DL]: Done driving station state, nothing else to do...
W (32462) wifi:Haven't to connect to a suitable AP now!
I (32599) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (32727) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (32730) chip[DL]: bleAdv Timeout : Start slow advertisement
I (32732) chip[DL]: Configuring CHIPoBLE advertising (interval 500 ms, connectable)
I (32737) chip[DL]: Device already advertising, stop active advertisement and restart
I (32744) NimBLE: GAP procedure initiated: stop advertising.
I (32750) NimBLE: GAP procedure initiated: advertise;
I (32754) NimBLE: disc_mode=2
I (32756) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=800 adv_itvl_max=800
I (32767) NimBLE:
I (33100) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (33184) SEN66: RTC is uninitialized, using current time as timestamp
W (33272) AS7341: RTC is uninitialized, using current time as timestamp
W (33320) BME688: RTC is uninitialized, using current time as timestamp
W (33463) wifi:Haven't to connect to a suitable AP now!
I (33601) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (33728) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (34102) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (34185) SEN66: RTC is uninitialized, using current time as timestamp
W (34274) AS7341: RTC is uninitialized, using current time as timestamp
W (34321) BME688: RTC is uninitialized, using current time as timestamp
W (34464) wifi:Haven't to connect to a suitable AP now!
I (34603) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (34729) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (35104) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (35186) SEN66: RTC is uninitialized, using current time as timestamp
W (35275) AS7341: RTC is uninitialized, using current time as timestamp
W (35322) BME688: RTC is uninitialized, using current time as timestamp
W (35465) wifi:Haven't to connect to a suitable AP now!
I (35605) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (35730) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (36106) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (36187) SEN66: RTC is uninitialized, using current time as timestamp
W (36276) AS7341: RTC is uninitialized, using current time as timestamp
W (36323) BME688: RTC is uninitialized, using current time as timestamp
W (36466) wifi:Haven't to connect to a suitable AP now!
I (36607) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (36731) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (37107) SENSOR_GATHERER: 2/2 tasks still running, waiting...
I (37122) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (37125) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (37126) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (37129) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (37137) wifi:Haven't to connect to a suitable AP now!
E (37142) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (37151) wifi:Haven't to connect to a suitable AP now!
I (37156) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (37163) wifi:Haven't to connect to a suitable AP now!
E (37168) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (37178) chip[DL]: Next WiFi station reconnect in 100 ms
I (37182) chip[DL]: Done driving station state, nothing else to do...
W (37188) SEN66: RTC is uninitialized, using current time as timestamp
W (37277) AS7341: RTC is uninitialized, using current time as timestamp
W (37283) wifi:Haven't to connect to a suitable AP now!
I (37284) chip[DL]: Attempting to connect WiFi station interface
I (37287) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (37290) wifi:Haven't to connect to a suitable AP now!
E (37295) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (37305) chip[DL]: Done driving station state, nothing else to do...
W (37324) BME688: RTC is uninitialized, using current time as timestamp
W (37468) wifi:Haven't to connect to a suitable AP now!
I (37608) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (37732) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (38109) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (38195) SEN66: RTC is uninitialized, using current time as timestamp
W (38278) AS7341: RTC is uninitialized, using current time as timestamp
W (38325) BME688: RTC is uninitialized, using current time as timestamp
W (38470) wifi:Haven't to connect to a suitable AP now!
I (38610) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (38733) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (39111) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (39196) SEN66: RTC is uninitialized, using current time as timestamp
W (39279) AS7341: RTC is uninitialized, using current time as timestamp
W (39326) BME688: RTC is uninitialized, using current time as timestamp
W (39471) wifi:Haven't to connect to a suitable AP now!
I (39612) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (39736) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (40113) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (40197) SEN66: RTC is uninitialized, using current time as timestamp
W (40280) AS7341: RTC is uninitialized, using current time as timestamp
W (40326) BME688: RTC is uninitialized, using current time as timestamp
W (40473) wifi:Haven't to connect to a suitable AP now!
I (40614) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (40737) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (41115) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (41198) SEN66: RTC is uninitialized, using current time as timestamp
W (41281) AS7341: RTC is uninitialized, using current time as timestamp
W (41327) BME688: RTC is uninitialized, using current time as timestamp
W (41474) wifi:Haven't to connect to a suitable AP now!
I (41616) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (41738) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (42105) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (42108) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (42109) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (42112) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
I (42116) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (42120) wifi:Haven't to connect to a suitable AP now!
E (42130) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (42140) wifi:Haven't to connect to a suitable AP now!
I (42144) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (42151) wifi:Haven't to connect to a suitable AP now!
E (42156) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (42166) chip[DL]: Next WiFi station reconnect in 100 ms
I (42170) chip[DL]: Done driving station state, nothing else to do...
W (42199) SEN66: RTC is uninitialized, using current time as timestamp
W (42271) wifi:Haven't to connect to a suitable AP now!
I (42272) chip[DL]: Attempting to connect WiFi station interface
I (42275) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (42278) wifi:Haven't to connect to a suitable AP now!
W (42283) AS7341: RTC is uninitialized, using current time as timestamp
E (42283) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (42299) chip[DL]: Done driving station state, nothing else to do...
W (42328) BME688: RTC is uninitialized, using current time as timestamp
W (42476) wifi:Haven't to connect to a suitable AP now!
I (42625) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (42739) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (43126) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (43200) SEN66: RTC is uninitialized, using current time as timestamp
W (43290) AS7341: RTC is uninitialized, using current time as timestamp
W (43329) BME688: RTC is uninitialized, using current time as timestamp
W (43482) wifi:Haven't to connect to a suitable AP now!
I (43627) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (43740) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (44128) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (44201) SEN66: RTC is uninitialized, using current time as timestamp
W (44291) AS7341: RTC is uninitialized, using current time as timestamp
W (44330) BME688: RTC is uninitialized, using current time as timestamp
W (44486) wifi:Haven't to connect to a suitable AP now!
I (44629) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (44741) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (45130) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (45202) SEN66: RTC is uninitialized, using current time as timestamp
W (45292) AS7341: RTC is uninitialized, using current time as timestamp
W (45331) BME688: RTC is uninitialized, using current time as timestamp
W (45487) wifi:Haven't to connect to a suitable AP now!
I (45631) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (45742) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (46132) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (46203) SEN66: RTC is uninitialized, using current time as timestamp
W (46293) AS7341: RTC is uninitialized, using current time as timestamp
W (46332) BME688: RTC is uninitialized, using current time as timestamp
W (46488) wifi:Haven't to connect to a suitable AP now!
I (46633) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (46743) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (47093) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (47095) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (47097) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (47100) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (47107) wifi:Haven't to connect to a suitable AP now!
E (47112) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (47122) wifi:Haven't to connect to a suitable AP now!
I (47126) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
I (47134) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (47134) wifi:Haven't to connect to a suitable AP now!
E (47144) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (47154) chip[DL]: Next WiFi station reconnect in 100 ms
I (47158) chip[DL]: Done driving station state, nothing else to do...
W (47204) SEN66: RTC is uninitialized, using current time as timestamp
W (47259) wifi:Haven't to connect to a suitable AP now!
I (47260) chip[DL]: Attempting to connect WiFi station interface
I (47263) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (47266) wifi:Haven't to connect to a suitable AP now!
E (47271) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (47281) chip[DL]: Done driving station state, nothing else to do...
W (47294) AS7341: RTC is uninitialized, using current time as timestamp
W (47333) BME688: RTC is uninitialized, using current time as timestamp
W (47493) wifi:Haven't to connect to a suitable AP now!
I (47639) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (47744) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (48140) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (48205) SEN66: RTC is uninitialized, using current time as timestamp
W (48295) AS7341: RTC is uninitialized, using current time as timestamp
W (48334) BME688: RTC is uninitialized, using current time as timestamp
W (48495) wifi:Haven't to connect to a suitable AP now!
I (48641) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (48747) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (49142) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (49206) SEN66: RTC is uninitialized, using current time as timestamp
W (49296) AS7341: RTC is uninitialized, using current time as timestamp
W (49335) BME688: RTC is uninitialized, using current time as timestamp
W (49496) wifi:Haven't to connect to a suitable AP now!
I (49643) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (49748) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (50144) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (50207) SEN66: RTC is uninitialized, using current time as timestamp
W (50297) AS7341: RTC is uninitialized, using current time as timestamp
W (50336) BME688: RTC is uninitialized, using current time as timestamp
W (50498) wifi:Haven't to connect to a suitable AP now!
I (50645) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (50749) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (51146) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (51208) SEN66: RTC is uninitialized, using current time as timestamp
W (51298) AS7341: RTC is uninitialized, using current time as timestamp
W (51337) BME688: RTC is uninitialized, using current time as timestamp
W (51500) wifi:Haven't to connect to a suitable AP now!
I (51647) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (51750) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (52081) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (52084) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (52085) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (52088) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (52096) wifi:Haven't to connect to a suitable AP now!
E (52100) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (52110) wifi:Haven't to connect to a suitable AP now!
I (52115) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (52122) wifi:Haven't to connect to a suitable AP now!
E (52127) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (52136) chip[DL]: Next WiFi station reconnect in 100 ms
I (52141) chip[DL]: Done driving station state, nothing else to do...
I (52148) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (52209) SEN66: RTC is uninitialized, using current time as timestamp
W (52242) wifi:Haven't to connect to a suitable AP now!
I (52243) chip[DL]: Attempting to connect WiFi station interface
I (52246) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (52249) wifi:Haven't to connect to a suitable AP now!
E (52254) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (52264) chip[DL]: Done driving station state, nothing else to do...
W (52299) AS7341: RTC is uninitialized, using current time as timestamp
W (52338) BME688: RTC is uninitialized, using current time as timestamp
W (52508) wifi:Haven't to connect to a suitable AP now!
I (52653) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (52751) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (53154) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (53210) SEN66: RTC is uninitialized, using current time as timestamp
W (53300) AS7341: RTC is uninitialized, using current time as timestamp
W (53339) BME688: RTC is uninitialized, using current time as timestamp
W (53509) wifi:Haven't to connect to a suitable AP now!
I (53655) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (53752) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (54156) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (54212) SEN66: RTC is uninitialized, using current time as timestamp
W (54301) AS7341: RTC is uninitialized, using current time as timestamp
W (54340) BME688: RTC is uninitialized, using current time as timestamp
W (54510) wifi:Haven't to connect to a suitable AP now!
I (54657) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (54753) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (55158) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (55213) SEN66: RTC is uninitialized, using current time as timestamp
W (55302) AS7341: RTC is uninitialized, using current time as timestamp
W (55341) BME688: RTC is uninitialized, using current time as timestamp
W (55511) wifi:Haven't to connect to a suitable AP now!
I (55659) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (55754) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (56160) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (56214) SEN66: RTC is uninitialized, using current time as timestamp
W (56303) AS7341: RTC is uninitialized, using current time as timestamp
W (56342) BME688: RTC is uninitialized, using current time as timestamp
W (56512) wifi:Haven't to connect to a suitable AP now!
I (56661) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (56755) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (57064) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (57067) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (57068) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (57071) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (57079) wifi:Haven't to connect to a suitable AP now!
E (57083) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (57093) wifi:Haven't to connect to a suitable AP now!
I (57097) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (57105) wifi:Haven't to connect to a suitable AP now!
E (57110) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (57119) chip[DL]: Next WiFi station reconnect in 100 ms
I (57124) chip[DL]: Done driving station state, nothing else to do...
I (57162) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (57215) SEN66: RTC is uninitialized, using current time as timestamp
W (57225) wifi:Haven't to connect to a suitable AP now!
I (57226) chip[DL]: Attempting to connect WiFi station interface
I (57230) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (57232) wifi:Haven't to connect to a suitable AP now!
E (57237) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (57247) chip[DL]: Done driving station state, nothing else to do...
W (57304) AS7341: RTC is uninitialized, using current time as timestamp
W (57344) BME688: RTC is uninitialized, using current time as timestamp
W (57517) wifi:Haven't to connect to a suitable AP now!
I (57663) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (57756) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (58164) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (58216) SEN66: RTC is uninitialized, using current time as timestamp
W (58305) AS7341: RTC is uninitialized, using current time as timestamp
W (58345) BME688: RTC is uninitialized, using current time as timestamp
W (58518) wifi:Haven't to connect to a suitable AP now!
I (58665) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (58757) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (59166) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (59217) SEN66: RTC is uninitialized, using current time as timestamp
W (59306) AS7341: RTC is uninitialized, using current time as timestamp
W (59346) BME688: RTC is uninitialized, using current time as timestamp
W (59519) wifi:Haven't to connect to a suitable AP now!
I (59667) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (59758) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (60168) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (60218) SEN66: RTC is uninitialized, using current time as timestamp
W (60307) AS7341: RTC is uninitialized, using current time as timestamp
W (60346) BME688: RTC is uninitialized, using current time as timestamp
W (60520) wifi:Haven't to connect to a suitable AP now!
I (60669) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (60759) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (61170) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (61219) SEN66: RTC is uninitialized, using current time as timestamp
W (61308) AS7341: RTC is uninitialized, using current time as timestamp
W (61347) BME688: RTC is uninitialized, using current time as timestamp
W (61521) wifi:Haven't to connect to a suitable AP now!
I (61671) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (61760) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (62047) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (62050) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (62051) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (62054) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (62062) wifi:Haven't to connect to a suitable AP now!
E (62067) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (62076) wifi:Haven't to connect to a suitable AP now!
I (62081) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (62088) wifi:Haven't to connect to a suitable AP now!
E (62093) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (62102) chip[DL]: Next WiFi station reconnect in 100 ms
I (62107) chip[DL]: Done driving station state, nothing else to do...
I (62172) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (62208) wifi:Haven't to connect to a suitable AP now!
I (62209) chip[DL]: Attempting to connect WiFi station interface
I (62212) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (62215) wifi:Haven't to connect to a suitable AP now!
W (62220) SEN66: RTC is uninitialized, using current time as timestamp
E (62220) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (62236) chip[DL]: Done driving station state, nothing else to do...
W (62309) AS7341: RTC is uninitialized, using current time as timestamp
W (62348) BME688: RTC is uninitialized, using current time as timestamp
W (62527) wifi:Haven't to connect to a suitable AP now!
I (62673) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (62761) DeviceStatus: RTC is uninitialized, using current time as timestamp
W (62925) wifi:Haven't to connect to a suitable AP now!
I (62925) KOTA: health up=61s status=CONNECTION_ERROR wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=0 heap=47259 psram=7.3M last_upload=never ota=registered
I (63174) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (63227) SEN66: RTC is uninitialized, using current time as timestamp
W (63310) AS7341: RTC is uninitialized, using current time as timestamp
W (63349) BME688: RTC is uninitialized, using current time as timestamp
W (63528) wifi:Haven't to connect to a suitable AP now!
I (63675) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (63762) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (64176) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (64228) SEN66: RTC is uninitialized, using current time as timestamp
W (64311) AS7341: RTC is uninitialized, using current time as timestamp
W (64350) BME688: RTC is uninitialized, using current time as timestamp
W (64529) wifi:Haven't to connect to a suitable AP now!
I (64617) SEN66: First measurement, waiting additional 120 seconds for sensor to stabilize...
I (64677) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (64763) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (65178) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (65229) SEN66: RTC is uninitialized, using current time as timestamp
W (65312) AS7341: RTC is uninitialized, using current time as timestamp
W (65351) BME688: RTC is uninitialized, using current time as timestamp
W (65530) wifi:Haven't to connect to a suitable AP now!
I (65679) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (65764) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (66180) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (66230) SEN66: RTC is uninitialized, using current time as timestamp
W (66313) AS7341: RTC is uninitialized, using current time as timestamp
W (66352) BME688: RTC is uninitialized, using current time as timestamp
W (66531) wifi:Haven't to connect to a suitable AP now!
I (66681) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (66765) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (67030) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (67033) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (67034) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (67037) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (67045) wifi:Haven't to connect to a suitable AP now!
E (67049) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (67059) wifi:Haven't to connect to a suitable AP now!
I (67063) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (67071) wifi:Haven't to connect to a suitable AP now!
E (67076) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (67085) chip[DL]: Next WiFi station reconnect in 100 ms
I (67090) chip[DL]: Done driving station state, nothing else to do...
I (67182) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (67191) wifi:Haven't to connect to a suitable AP now!
I (67192) chip[DL]: Attempting to connect WiFi station interface
I (67195) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (67198) wifi:Haven't to connect to a suitable AP now!
E (67203) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (67213) chip[DL]: Done driving station state, nothing else to do...
W (67231) SEN66: RTC is uninitialized, using current time as timestamp
W (67314) AS7341: RTC is uninitialized, using current time as timestamp
W (67353) BME688: RTC is uninitialized, using current time as timestamp
W (67537) wifi:Haven't to connect to a suitable AP now!
I (67683) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (67766) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (68184) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (68232) SEN66: RTC is uninitialized, using current time as timestamp
W (68315) AS7341: RTC is uninitialized, using current time as timestamp
W (68354) BME688: RTC is uninitialized, using current time as timestamp
W (68542) wifi:Haven't to connect to a suitable AP now!
I (68685) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (68767) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (69186) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (69233) SEN66: RTC is uninitialized, using current time as timestamp
W (69316) AS7341: RTC is uninitialized, using current time as timestamp
W (69355) BME688: RTC is uninitialized, using current time as timestamp
W (69546) wifi:Haven't to connect to a suitable AP now!
I (69687) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (69768) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (70188) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (70234) SEN66: RTC is uninitialized, using current time as timestamp
W (70317) AS7341: RTC is uninitialized, using current time as timestamp
W (70356) BME688: RTC is uninitialized, using current time as timestamp
W (70547) wifi:Haven't to connect to a suitable AP now!
I (70689) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (70769) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (71190) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (71235) SEN66: RTC is uninitialized, using current time as timestamp
W (71318) AS7341: RTC is uninitialized, using current time as timestamp
W (71357) BME688: RTC is uninitialized, using current time as timestamp
W (71548) wifi:Haven't to connect to a suitable AP now!
I (71691) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (71770) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (72013) wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
I (72015) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 5
I (72017) chip[DL]: WIFI_EVENT_STA_DISCONNECTED
I (72020) chip[DL]: WiFi station state change: Connecting -> Connecting_Failed
W (72027) wifi:Haven't to connect to a suitable AP now!
E (72032) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
W (72042) wifi:Haven't to connect to a suitable AP now!
I (72046) chip[DL]: WiFi station state change: Connecting_Failed -> NotConnected
W (72054) wifi:Haven't to connect to a suitable AP now!
E (72059) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (72068) chip[DL]: Next WiFi station reconnect in 100 ms
I (72073) chip[DL]: Done driving station state, nothing else to do...
W (72173) wifi:Haven't to connect to a suitable AP now!
I (72175) chip[DL]: Attempting to connect WiFi station interface
I (72178) chip[DL]: WiFi station state change: NotConnected -> Connecting
W (72181) wifi:Haven't to connect to a suitable AP now!
E (72185) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F
I (72191) SENSOR_GATHERER: 2/2 tasks still running, waiting...
I (72195) chip[DL]: Done driving station state, nothing else to do...
W (72236) SEN66: RTC is uninitialized, using current time as timestamp
W (72319) AS7341: RTC is uninitialized, using current time as timestamp
W (72358) BME688: RTC is uninitialized, using current time as timestamp
W (72552) wifi:Haven't to connect to a suitable AP now!
I (72700) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (72771) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (73201) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (73237) SEN66: RTC is uninitialized, using current time as timestamp
W (73320) AS7341: RTC is uninitialized, using current time as timestamp
W (73359) BME688: RTC is uninitialized, using current time as timestamp
W (73553) wifi:Haven't to connect to a suitable AP now!
I (73702) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (73772) DeviceStatus: RTC is uninitialized, using current time as timestamp
I (74203) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (74238) SEN66: RTC is uninitialized, using current time as timestamp
W (74321) AS7341: RTC is uninitialized, using current time as timestamp
W (74360) BME688: RTC is uninitialized, using current time as timestamp
W (74555) wifi:Haven't to connect to a suitable AP now!
I (74581) SENSOR_GATHERER: 3/4 tasks still running, waiting...
I (74627) INFLUX_BUFFER: Buffer file does not exist, returning empty buffer
I (74628) INFLUX_BUFFER: Buffer is empty, nothing to flush
I (74704) SENSOR_GATHERER: 2/2 tasks still running, waiting...
W (74773) DeviceStatus: RTC is uninitialized, using current time as timestamp
--- disconnected ---
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+177
View File
@@ -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
+10
View File
@@ -247,3 +247,13 @@ def test_live_brownout_loop_is_a_power_problem():
assert top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5 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 "reboot.loop" in {c.rule_id for c in top.consequences}
assert r.verdict.startswith("Problem: Power supply") 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()
+12
View File
@@ -12,3 +12,15 @@ def test_other_ports_not_sensorbox():
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None) bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
assert not bt.is_sensorbox and bt.mac is None assert not bt.is_sensorbox and bt.mac is None
assert bt.label == "COM3 - Standard Serial over Bluetooth link" assert bt.label == "COM3 - Standard Serial over Bluetooth link"
def test_macos_lists_only_callout_devices(monkeypatch):
from types import SimpleNamespace
from kota_tech.serialio import ports
fake = [SimpleNamespace(device=d, description="USB JTAG/serial debug unit", vid=0x303A, pid=0x1001,
serial_number="A4:CB:8F:C9:46:F8")
for d in ("/dev/tty.usbmodem1101", "/dev/cu.usbmodem1101")]
monkeypatch.setattr(ports.sys, "platform", "darwin")
monkeypatch.setattr(ports.list_ports, "comports", lambda: fake)
assert [p.device for p in ports.list_serial_ports()] == ["/dev/cu.usbmodem1101"]
+11
View File
@@ -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() assert work.parent == tmp_path / "tmp" and work.is_dir()
(work / "result.json").write_text('{"ok": true}', encoding="utf-8") (work / "result.json").write_text('{"ok": true}', encoding="utf-8")
assert (work / "result.json").read_text(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)
+19
View File
@@ -0,0 +1,19 @@
"""The whole window builds (catches errors that only show when the app starts)."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
def test_main_window_builds_with_all_tabs():
from PySide6.QtWidgets import QApplication
from kota_tech.ui.main_window import MainWindow
app = QApplication.instance() or QApplication([])
w = MainWindow()
tabs = w.centralWidget()
assert [tabs.tabText(i) for i in range(tabs.count())] == \
["Quick start", "Serial monitor", "Flash firmware", "Wi-Fi (USB)", "SD card"]
assert not w.wifi_tab.write_btn.isEnabled() # nothing connected, nothing typed
w.serial_tab.shutdown()
app.processEvents()
+74
View File
@@ -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
+107
View File
@@ -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
Generated
+1 -1
View File
@@ -250,7 +250,7 @@ wheels = [
[[package]] [[package]]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.7.0" version = "0.9.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "esptool" }, { name = "esptool" },