Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5db49ee2 | ||
|
|
bb933ff748 | ||
|
|
66cb804102 | ||
|
|
2b6d43eb45 | ||
|
|
834bad2bce | ||
|
|
c41fa101b6 | ||
|
|
e299ef40f0 | ||
|
|
39f0d1718c |
No files matched your search
@@ -5,6 +5,9 @@ Działa na Windows 10/11 i macOS 13 (Ventura) lub nowszym, na Macach z Intelem i
|
|||||||
|
|
||||||
Repozytorium: https://gitea.kota-app.com/kkallas/kota-technician-tool (dostęp nadaje właściciel repozytorium).
|
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
|
## Dla technika: dwa pliki
|
||||||
|
|
||||||
| System | Aktualizacja | Uruchomienie |
|
| System | Aktualizacja | Uruchomienie |
|
||||||
@@ -47,6 +50,13 @@ 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`).
|
Sesje, kopie zapasowe flash i ustawienia są w `~/KotaTechnicianTool` (Windows: `C:\Users\<użytkownik>\KotaTechnicianTool`).
|
||||||
|
|
||||||
|
## Dane w InfluxDB
|
||||||
|
|
||||||
|
Kontrola **Sensor data in the database (InfluxDB)** (Guided troubleshooting, także ostatni krok Quick check)
|
||||||
|
sprawdza online, czy do InfluxDB trafiają dane z czujników boxa, a nie tylko jego status. Wymaga internetu
|
||||||
|
na laptopie i jednorazowego ustawienia tokenu tylko do odczytu (od zespołu firmware):
|
||||||
|
**Settings > InfluxDB access...**. Tokenu nie zapisujemy w repozytorium.
|
||||||
|
|
||||||
## Port sensorboxa
|
## Port sensorboxa
|
||||||
|
|
||||||
- Windows: `COMx - Sensorbox (native USB) [MAC]`
|
- Windows: `COMx - Sensorbox (native USB) [MAC]`
|
||||||
|
|||||||
@@ -12,3 +12,30 @@
|
|||||||
the firmware team should bump the version for the next build.
|
the firmware team should bump the version for the next build.
|
||||||
- error_ids.csv updated from the firmware (E-CON-01).
|
- error_ids.csv updated from the firmware (E-CON-01).
|
||||||
- Test that builds the main window (catches start-up errors).
|
- Test that builds the main window (catches start-up errors).
|
||||||
|
- New check **Sensor data in the database (InfluxDB)** (Guided troubleshooting; also the last step of the
|
||||||
|
Quick check on the green path): queries InfluxDB online (read-only) for the box's newest points and tells
|
||||||
|
apart "sensor data arrives", "status arrives but sensor data does not", "offline now" and "never seen".
|
||||||
|
Polls every 30 s for up to 4 minutes. With a stopped RTC in the log it concludes that the sensor readings
|
||||||
|
are lost (they are stamped 1 Jan 2000) - found on box A4:CB:8F:C9:47:3C, which reported only
|
||||||
|
device_status and microphone. The result and the per-measurement times go into the report and the
|
||||||
|
service note.
|
||||||
|
- Settings > **InfluxDB access...**: address, organisation, bucket and a read token (or the
|
||||||
|
`KOTA_INFLUX_TOKEN` environment variable). Defaults: influx.kota-app.com, org `kota`, bucket `sensorbox`.
|
||||||
|
The token is never written to the session log or the service report.
|
||||||
|
- macOS: Connect no longer restarts the box (pyserial dropped DTR before RTS, passing through the
|
||||||
|
USB-Serial-JTAG reset state). Windows unchanged.
|
||||||
|
- **Serial monitor redesigned.** One green **Test box** button: restarts the box, records the start-up and
|
||||||
|
then checks InfluxDB online (data must be newer than the restart), about 4 minutes. A status strip shows
|
||||||
|
what the test is doing and its result. The test checklist (start-up steps + the new row
|
||||||
|
"Sensor data in InfluxDB (online check)") and the findings are the main view; the live log, guided
|
||||||
|
troubleshooting and box details (LED, firmware, self-report) are tabs on the right. Rarely used actions
|
||||||
|
moved to **More**.
|
||||||
|
- Light colour scheme on every system: with macOS dark mode the status colours were unreadable.
|
||||||
|
Larger log font on macOS.
|
||||||
|
- New finding **Box sends an invalid value (-inf/nan) - InfluxDB rejects all its data**: a silent microphone
|
||||||
|
makes the firmware upload `dbfs=-inf,db=-inf`; InfluxDB then refuses the whole upload (HTTP 400), so neither
|
||||||
|
the status nor the sensor data arrive (box A4:CB:8F:C9:47:3C). The InfluxDB check names it as the cause.
|
||||||
|
- The serial port is opened exclusively on macOS/Linux: a second tool window no longer steals half of the
|
||||||
|
log lines; it reports that the port is in use.
|
||||||
|
- Start-up step "Data uploaded to InfluxDB" renamed "Upload accepted by InfluxDB (box log)" - it comes
|
||||||
|
from the box's own log, the online row checks the database.
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# KOTA Sensorbox Technician Tool – instrukcja dla technika
|
||||||
|
|
||||||
|
Program służy do sprawdzenia sensorboxa przez USB, podłączenia go do Wi-Fi bez otwierania obudowy,
|
||||||
|
wgrania firmware i przygotowania karty SD. Działa na Windows 10/11 i macOS 13 lub nowszym.
|
||||||
|
|
||||||
|
> ⚠️ **Między boxem a komputerem musi być po drodze wtyczka USB 2.0 typu A (prostokątna).**
|
||||||
|
> Może to być kabel USB 2.0 A wpięty do komputera albo do huba – hub jest w porządku.
|
||||||
|
> Połączenie wyłącznie przez USB-C (kabel USB-C–USB-C, przejściówka USB-C bez odcinka USB 2.0 A)
|
||||||
|
> często kończy się tym, że box nie jest widoczny w systemie („Unknown USB Device”) albo restartuje się przez
|
||||||
|
> spadki napięcia. Kabel musi przenosić dane – kabel „tylko do ładowania” nie zadziała.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spis treści
|
||||||
|
|
||||||
|
1. [Instalacja (raz na komputer)](#1-instalacja-raz-na-komputer)
|
||||||
|
2. [Aktualizacja i uruchomienie](#2-aktualizacja-i-uruchomienie)
|
||||||
|
3. [Podłączenie boxa](#3-podłączenie-boxa)
|
||||||
|
4. [Diagnoza boxa – zakładka Serial monitor](#4-diagnoza-boxa--zakładka-serial-monitor)
|
||||||
|
5. [Sprawdzenie krok po kroku – Guided troubleshooting](#5-sprawdzenie-krok-po-kroku--guided-troubleshooting)
|
||||||
|
6. [Wi-Fi przez USB – zakładka Wi-Fi (USB)](#6-wi-fi-przez-usb--zakładka-wi-fi-usb)
|
||||||
|
7. [Wgrywanie firmware – zakładka Flash firmware](#7-wgrywanie-firmware--zakładka-flash-firmware)
|
||||||
|
8. [Karta SD – zakładka SD card](#8-karta-sd--zakładka-sd-card)
|
||||||
|
9. [Raport serwisowy](#9-raport-serwisowy)
|
||||||
|
10. [Co oznacza kolor diody](#10-co-oznacza-kolor-diody)
|
||||||
|
11. [Najczęstsze problemy](#11-najczęstsze-problemy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Instalacja (raz na komputer)
|
||||||
|
|
||||||
|
1. Na https://gitea.kota-app.com/kkallas/kota-technician-tool otwórz **Releases** i przy najnowszej wersji
|
||||||
|
pobierz **Source Code (ZIP)**.
|
||||||
|
2. Rozpakuj do zwykłego folderu, np. `Dokumenty\KOTA\technician-tool`
|
||||||
|
(macOS: `~/Documents/KOTA/technician-tool`). **Nie** do folderu Google Drive ani OneDrive.
|
||||||
|
3. Uruchom plik aktualizacji dla swojego systemu (patrz rozdział 2). Za pierwszym razem:
|
||||||
|
- zapyta o login do Gitea (hasło lub token) – system go zapamięta,
|
||||||
|
- za Twoją zgodą zainstaluje brakujące narzędzia (Git, `uv`),
|
||||||
|
- pobierze biblioteki (1–2 minuty, potrzebny internet).
|
||||||
|
|
||||||
|
**macOS:** jeśli Finder nie chce otworzyć pliku `.command`, otwórz Terminal, wpisz `bash ` (ze spacją),
|
||||||
|
przeciągnij plik do okna Terminala i naciśnij Enter. Po pierwszej aktualizacji dwuklik zaczyna działać.
|
||||||
|
|
||||||
|
## 2. Aktualizacja i uruchomienie
|
||||||
|
|
||||||
|
W folderze narzędzia potrzebujesz tylko dwóch plików:
|
||||||
|
|
||||||
|
| System | Aktualizacja | Uruchomienie |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows | `update_windows.bat` | `run_windows.bat` |
|
||||||
|
| macOS | `update_macos.command` | `run_macos.command` |
|
||||||
|
|
||||||
|
- **Aktualizacja** – uruchom przed wyjazdem do klienta albo gdy zespół ogłosi nową wersję. Pobiera tylko zmiany
|
||||||
|
i pokazuje ich listę. Wymaga internetu.
|
||||||
|
- **Uruchomienie** – otwiera program. Działa bez internetu.
|
||||||
|
|
||||||
|
Po uruchomieniu otwiera się zakładka **Quick start** ze skróconą instrukcją i tabelą kolorów diody:
|
||||||
|
|
||||||
|

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

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

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

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

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

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

|
||||||
|
|
||||||
|
1. **Settings** – ustawienia wczytują się z pliku od zespołu (INFLUX_URL, INFLUX_TOKEN). Nie zmieniaj ich bez
|
||||||
|
potrzeby. Opcjonalnie możesz wpisać Wi-Fi (sieć i hasło).
|
||||||
|
2. Włóż kartę do czytnika w komputerze. Na liście pojawiają się tylko karty wymienne – dyski komputera nie są
|
||||||
|
pokazywane.
|
||||||
|
3. **Check card** – sprawdza kartę bez zmieniania czegokolwiek.
|
||||||
|
**Write ENV.TXT only** – zapisuje ustawienia, zostawia pomiary na karcie.
|
||||||
|
**Format card and write ENV.TXT** – kasuje całą kartę (FAT32) i zapisuje ustawienia.
|
||||||
|
4. Program sam wysuwa kartę – dopiero wtedy ją wyjmij.
|
||||||
|
|
||||||
|
## 9. Raport serwisowy
|
||||||
|
|
||||||
|
Kliknij **Service report…** w zakładce Serial monitor. Otworzy się strona boxa w systemie KOTA Serwis
|
||||||
|
z wpisaną diagnozą (werdykt, stan boxa, etapy startu, problemy z liczbą wystąpień i czasem). Sprawdź treść,
|
||||||
|
dopisz lokalizację i uwagi, kliknij **Zapisz**.
|
||||||
|
|
||||||
|
Zaznaczenie **Open service report on disconnect** otwiera raport automatycznie po rozłączeniu boxa.
|
||||||
|
|
||||||
|
## 10. Co oznacza kolor diody
|
||||||
|
|
||||||
|
| Kolor | Wzór | Znaczenie | Co zrobić |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Zielony | ciągły | Wszystko OK | Nic |
|
||||||
|
| Cyan (turkusowy) | pulsuje / szybko miga | Nie sparowany w Matter / telefon łączy się | Sparuj w aplikacji Matter albo ustaw Wi-Fi przez USB |
|
||||||
|
| Biały | szybko miga / ciągły / pulsuje | Błąd karty SD lub ENV.TXT / reset fabryczny / tryb konfiguracji Wi-Fi | Sprawdź kartę SD (zakładka SD card) |
|
||||||
|
| Czerwony | 1–6 błysków | Awaria czujnika: 1 BME688, 2 SEN66, 3 AS7341, 4 mikrofon, 5 flicker, 6 test po aktualizacji | Policz błyski, sprawdź przewód tego czujnika |
|
||||||
|
| Czerwony | ciągły | Inny błąd wewnętrzny / brak pamięci | Zapisz log, wyślij zespołowi |
|
||||||
|
| Niebieski | 1 / 2 błyski | Zegar RTC nie odpowiada / zablokowana magistrala I2C | Sprawdź przewód I2C między płytkami |
|
||||||
|
| Fioletowy | ciągły / miga | Brak sieci / serwer odrzuca box (złe ENV.TXT) | Sprawdź Wi-Fi / wgraj poprawne ENV.TXT |
|
||||||
|
| Żółty | miga coraz szybciej | Pobiera aktualizację | Nie wyłączaj, poczekaj |
|
||||||
|
| Pomarańczowy | ciągły | Test po aktualizacji (~2,5 min) | Poczekaj |
|
||||||
|
| Zgaszona | – | Firmware nie działa lub brak zasilania | Sprawdź włącznik i kabel; wgraj firmware |
|
||||||
|
|
||||||
|
Mała dioda obok (heartbeat): miga raz na sekundę = online, podwójnie = offline, nie miga = firmware stoi.
|
||||||
|
|
||||||
|
## 11. Najczęstsze problemy
|
||||||
|
|
||||||
|
| Objaw | Przyczyna | Co zrobić |
|
||||||
|
|---|---|---|
|
||||||
|
| Boxa nie ma na liście portów | Brak wtyczki USB 2.0 A po drodze (samo USB-C), kabel tylko do ładowania, box wyłączony | **Połączenie z wtyczką USB 2.0 typu A** (do komputera lub huba), kabel z transmisją danych, włącznik ON, odłącz i podłącz ponownie. W Windows sprawdź Menedżer urządzeń – „Unknown USB Device” oznacza problem z kablem/portem/zasilaniem. |
|
||||||
|
| Diagnoza: „Power supply voltage drops (brown-out)”, box się restartuje | Za słabe zasilanie | Włącznik boxa ON, inny port USB-A, zasilacz; naładuj baterię |
|
||||||
|
| „The box does not answer USB commands” (Wi-Fi USB) | Stary firmware | Wgraj najnowszy firmware (App only) |
|
||||||
|
| „Clock chip was stopped at start-up” | Pusta bateria zegara RTC | Zegar ustawi się sam po połączeniu z internetem; przy powtórce wymień baterię pastylkową |
|
||||||
|
| „Box is not connected to Wi-Fi (not commissioned in Matter)” | Box nie jest sparowany | Sparuj w Matter (przycisk **Matter pairing QR code…**) albo ustaw Wi-Fi przez USB |
|
||||||
|
| Wgrywanie nie może połączyć się z boxem | Port zajęty lub zły kabel | Zamknij inne programy, inny kabel/port; ostatecznie BOOT + RESET (rozdział 7) |
|
||||||
|
|
||||||
|
Jeśli problem się powtarza: **Open logs folder** → wyślij plik sesji zespołowi firmware razem z MAC boxa.
|
||||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -1,13 +1,42 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtGui import QColor, QPalette
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from .ui.main_window import MainWindow
|
from .ui.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def light_palette() -> QPalette:
|
||||||
|
"""The same light look on every system. All status colours in the tool are
|
||||||
|
chosen for a light background; with macOS dark mode they were unreadable."""
|
||||||
|
p = QPalette()
|
||||||
|
roles = {
|
||||||
|
QPalette.ColorRole.Window: "#f4f5f7",
|
||||||
|
QPalette.ColorRole.WindowText: "#1d1d1f",
|
||||||
|
QPalette.ColorRole.Base: "#ffffff",
|
||||||
|
QPalette.ColorRole.AlternateBase: "#f0f2f5",
|
||||||
|
QPalette.ColorRole.Text: "#1d1d1f",
|
||||||
|
QPalette.ColorRole.Button: "#e9ebef",
|
||||||
|
QPalette.ColorRole.ButtonText: "#1d1d1f",
|
||||||
|
QPalette.ColorRole.ToolTipBase: "#ffffe8",
|
||||||
|
QPalette.ColorRole.ToolTipText: "#1d1d1f",
|
||||||
|
QPalette.ColorRole.PlaceholderText: "#8a8a8a",
|
||||||
|
QPalette.ColorRole.Highlight: "#2e6da4",
|
||||||
|
QPalette.ColorRole.HighlightedText: "#ffffff",
|
||||||
|
QPalette.ColorRole.Link: "#1f5fa8",
|
||||||
|
QPalette.ColorRole.BrightText: "#ffffff",
|
||||||
|
}
|
||||||
|
for role, colour in roles.items():
|
||||||
|
p.setColor(role, QColor(colour))
|
||||||
|
for role in (QPalette.ColorRole.WindowText, QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText):
|
||||||
|
p.setColor(QPalette.ColorGroup.Disabled, role, QColor("#9a9a9a"))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setStyle("Fusion")
|
app.setStyle("Fusion")
|
||||||
|
app.setPalette(light_palette())
|
||||||
win = MainWindow()
|
win = MainWindow()
|
||||||
win.show()
|
win.show()
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ MILESTONES = [
|
|||||||
("wifi_connected", "Connected to Wi-Fi", False),
|
("wifi_connected", "Connected to Wi-Fi", False),
|
||||||
("time_synced", "Clock set from the Internet", False),
|
("time_synced", "Clock set from the Internet", False),
|
||||||
("clock_verified", "Clock keeps time (RTC verified)", True),
|
("clock_verified", "Clock keeps time (RTC verified)", True),
|
||||||
("data_uploaded", "Data uploaded to InfluxDB", False),
|
("data_uploaded", "Upload accepted by InfluxDB (box log)", False),
|
||||||
("ota_registered", "Registered for firmware updates", False),
|
("ota_registered", "Registered for firmware updates", False),
|
||||||
("ota_checked", "Checked for firmware updates", False),
|
("ota_checked", "Checked for firmware updates", False),
|
||||||
("ota_update_started", "Firmware update started", True),
|
("ota_update_started", "Firmware update started", True),
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ GUI-independent. The runner is fed the same LogLines as the diagnostics
|
|||||||
engine, reads the engine's report to route and to skip questions the log
|
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. "influx" steps ask the host to query InfluxDB
|
||||||
|
(on_action("influx")); the host answers with influx_result().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,7 +25,9 @@ GUIDES_PATH = Path(__file__).with_name("guides.toml")
|
|||||||
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", "influx"}
|
||||||
|
# Results of an InfluxDB check (service.influx.OUTCOMES).
|
||||||
|
INFLUX_OUTCOMES = ("ok", "sensors_missing", "silent", "never", "unavailable")
|
||||||
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
|
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
|
||||||
"error": Severity.ERROR, "critical": Severity.CRITICAL}
|
"error": Severity.ERROR, "critical": Severity.CRITICAL}
|
||||||
|
|
||||||
@@ -58,9 +61,12 @@ class Step:
|
|||||||
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
|
||||||
|
outcomes: dict[str, str] = field(default_factory=dict) # influx result -> target
|
||||||
|
poll_s: float = 30.0 # influx: query again after this many seconds
|
||||||
|
|
||||||
def targets(self) -> list[str]:
|
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 += list(self.outcomes.values())
|
||||||
t += [x for x in (self.default, self.next, self.on_ok, self.on_fail, self.on_timeout, self.skip_to) if x]
|
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
|
||||||
|
|
||||||
@@ -103,6 +109,7 @@ def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
|
|||||||
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
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)),
|
||||||
|
outcomes=dict(s.get("outcomes", {})), poll_s=float(s.get("poll_s", 30)),
|
||||||
)
|
)
|
||||||
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
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", [])}
|
||||||
@@ -123,6 +130,10 @@ def _validate(guides: dict[str, Guide]) -> None:
|
|||||||
raise ValueError(f"{g.id}.{s.id}: unknown LED colours {set(s.route) - set(LED_ANSWERS)}")
|
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.type == "influx" and set(INFLUX_OUTCOMES) - set(s.outcomes):
|
||||||
|
raise ValueError(f"{g.id}.{s.id}: influx step needs outcomes {set(INFLUX_OUTCOMES) - set(s.outcomes)}")
|
||||||
|
if set(s.outcomes) - set(INFLUX_OUTCOMES):
|
||||||
|
raise ValueError(f"{g.id}.{s.id}: unknown influx outcomes {set(s.outcomes) - set(INFLUX_OUTCOMES)}")
|
||||||
if s.tool_action not in (None, "reset"):
|
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():
|
||||||
@@ -191,6 +202,10 @@ class GuideRunner:
|
|||||||
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()
|
||||||
|
self._influx_pending = False
|
||||||
|
self._influx_next = 0.0
|
||||||
|
self._influx_last: str | None = None
|
||||||
|
self._influx_noted = ""
|
||||||
|
|
||||||
def start(self, guide_id: str) -> None:
|
def start(self, guide_id: str) -> None:
|
||||||
self.cancel()
|
self.cancel()
|
||||||
@@ -227,6 +242,28 @@ class GuideRunner:
|
|||||||
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")
|
||||||
|
elif s.type == "influx":
|
||||||
|
self._record(s, "technician skipped the InfluxDB check")
|
||||||
|
self._finish_influx(self._influx_last or "unavailable")
|
||||||
|
|
||||||
|
def influx_result(self, outcome: str, summary: str, details: list[str] | tuple[str, ...] = ()) -> None:
|
||||||
|
"""Result of the host's InfluxDB query for the current influx step."""
|
||||||
|
s = self.step
|
||||||
|
if s is None or s.type != "influx" or not self._influx_pending:
|
||||||
|
return # stale answer: the technician moved on
|
||||||
|
self._influx_pending = False
|
||||||
|
self._influx_last = outcome
|
||||||
|
note = "InfluxDB: " + "; ".join([summary, *details])
|
||||||
|
if note != self._influx_noted: # polls repeat the same answer
|
||||||
|
self._influx_noted = note
|
||||||
|
self.engine.note_technician(note)
|
||||||
|
self._on_log(f"InfluxDB check: {summary}")
|
||||||
|
waited = self._clock() - self._wait_started
|
||||||
|
if outcome in ("ok", "unavailable") or waited >= s.timeout_s:
|
||||||
|
self._finish_influx(outcome)
|
||||||
|
else:
|
||||||
|
self._influx_next = self._clock() + s.poll_s
|
||||||
|
self.note = f"{summary}. Checking again in {s.poll_s:.0f} s..."
|
||||||
|
|
||||||
def back(self) -> None:
|
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.
|
||||||
@@ -256,6 +293,11 @@ class GuideRunner:
|
|||||||
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")
|
||||||
|
elif s is not None and s.type == "influx" and not self._influx_pending:
|
||||||
|
if self._clock() - self._wait_started >= s.timeout_s:
|
||||||
|
self._finish_influx(self._influx_last or "unavailable")
|
||||||
|
elif self._clock() >= self._influx_next:
|
||||||
|
self._request_influx()
|
||||||
|
|
||||||
# --------------------------------------------------------------- view
|
# --------------------------------------------------------------- view
|
||||||
def view(self) -> View:
|
def view(self) -> View:
|
||||||
@@ -268,7 +310,7 @@ class GuideRunner:
|
|||||||
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
|
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 in ("wait", "influx"):
|
||||||
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
|
||||||
@@ -316,6 +358,27 @@ class GuideRunner:
|
|||||||
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")
|
||||||
|
if s.type == "influx":
|
||||||
|
self._wait_started = self._clock()
|
||||||
|
self._influx_last = None
|
||||||
|
self._request_influx()
|
||||||
|
|
||||||
|
def _request_influx(self) -> None:
|
||||||
|
self._influx_pending = True
|
||||||
|
if not self._on_action("influx"):
|
||||||
|
self._influx_pending = False
|
||||||
|
self._influx_last = "unavailable"
|
||||||
|
self._record(self.step, "InfluxDB check not possible from this computer")
|
||||||
|
self._finish_influx("unavailable")
|
||||||
|
|
||||||
|
def _finish_influx(self, outcome: str) -> None:
|
||||||
|
s = self.step
|
||||||
|
self._influx_pending = False
|
||||||
|
target = s.outcomes.get(outcome)
|
||||||
|
if target is None:
|
||||||
|
return
|
||||||
|
self._push()
|
||||||
|
self._go(target)
|
||||||
|
|
||||||
def _auto(self, s: Step, why: str, target: str | None) -> None:
|
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}")
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
# counts as ok (used for "watch for N seconds").
|
# counts as ok (used for "watch for N seconds").
|
||||||
# route automatic: first current root finding whose rule id is in
|
# route automatic: first current root finding whose rule id is in
|
||||||
# `routes` decides; otherwise `default`.
|
# `routes` decides; otherwise `default`.
|
||||||
|
# influx the tool asks InfluxDB (online) whether this box's data arrives,
|
||||||
|
# again every `poll_s` until "ok" or `timeout_s`; `outcomes` maps
|
||||||
|
# ok / sensors_missing / silent / never / unavailable -> target.
|
||||||
# Targets: "<step id>", "end:<end id>", "guide:<guide id>".
|
# Targets: "<step id>", "end:<end id>", "guide:<guide id>".
|
||||||
# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to).
|
# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to).
|
||||||
# Text placeholders: {expected_led} {failing_sensors} {top_finding}
|
# Text placeholders: {expected_led} {failing_sensors} {top_finding}
|
||||||
@@ -48,14 +51,7 @@ options = [
|
|||||||
id = "green_route"
|
id = "green_route"
|
||||||
type = "route"
|
type = "route"
|
||||||
routes = { "rtc.clock_not_running" = "guide:clock", "mic.silent" = "guide:mic", "mic.disabled" = "guide:mic", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network" }
|
routes = { "rtc.clock_not_running" = "guide:clock", "mic.silent" = "guide:mic", "mic.disabled" = "guide:mic", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network" }
|
||||||
default = "end:healthy"
|
default = "guide:influx"
|
||||||
|
|
||||||
[[guide.end]]
|
|
||||||
id = "healthy"
|
|
||||||
severity = "ok"
|
|
||||||
title = "Box looks healthy"
|
|
||||||
text = "Green LED, firmware running and no problems in the log."
|
|
||||||
actions = ["If the customer still reports missing data, keep the tool connected for 5 minutes and check that 'Data uploaded to InfluxDB' gets ticked."]
|
|
||||||
|
|
||||||
[[guide.end]]
|
[[guide.end]]
|
||||||
id = "hung"
|
id = "hung"
|
||||||
@@ -169,6 +165,7 @@ text = "Pairing, Internet time and the first upload all worked."
|
|||||||
actions = [
|
actions = [
|
||||||
"The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.",
|
"The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.",
|
||||||
"It should end green.",
|
"It should end green.",
|
||||||
|
"Then run the check 'Sensor data in the database' - the first upload may carry only the box status.",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[guide.end]]
|
[[guide.end]]
|
||||||
@@ -738,3 +735,99 @@ severity = "error"
|
|||||||
title = "Microphone records nothing"
|
title = "Microphone records nothing"
|
||||||
text = "Even loud noise next to the box gives no signal, although the switch is on."
|
text = "Even loud noise next to the box gives no signal, although the switch is on."
|
||||||
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
|
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
|
||||||
|
|
||||||
|
# ================================================================== influx
|
||||||
|
[[guide]]
|
||||||
|
id = "influx"
|
||||||
|
title = "Sensor data in the database (InfluxDB)"
|
||||||
|
summary = "Checks online that the sensor readings of this box reach InfluxDB, not only its status."
|
||||||
|
recommend = ["always"]
|
||||||
|
start = "check"
|
||||||
|
|
||||||
|
[[guide.step]]
|
||||||
|
id = "check"
|
||||||
|
type = "influx"
|
||||||
|
text = "Checking in InfluxDB that this box's data arrives: device status, microphone and the sensors (AS7341, BME688, SEN66). Keep the box switched on and online. The tool checks again every 30 s for up to 4 minutes (the air sensor needs about 2 minutes after a restart). The laptop needs Internet for this check."
|
||||||
|
timeout_s = 240
|
||||||
|
poll_s = 30
|
||||||
|
outcomes = { ok = "end:data_ok", sensors_missing = "why", silent = "why_silent", never = "why_silent", unavailable = "end:unavailable" }
|
||||||
|
|
||||||
|
[[guide.step]]
|
||||||
|
id = "why_silent"
|
||||||
|
type = "route"
|
||||||
|
routes = { "influx.invalid_value" = "end:invalid_value" }
|
||||||
|
default = "end:silent"
|
||||||
|
|
||||||
|
[[guide.step]]
|
||||||
|
id = "why"
|
||||||
|
type = "route"
|
||||||
|
routes = { "influx.invalid_value" = "end:invalid_value", "kota.rtc_stopped" = "end:sensors_clock", "rtc.clock_not_running" = "end:sensors_clock", "kota.rtc_not_ticking" = "end:sensors_clock", "kota.time_invalid" = "end:sensors_clock", "kota.rtc_read_failed" = "guide:rtc_i2c", "rtc.init_failed" = "guide:rtc_i2c", "sensor.init_failed" = "guide:sensors", "kota.sensor_missing" = "guide:sensors" }
|
||||||
|
default = "end:sensors_missing"
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "data_ok"
|
||||||
|
severity = "ok"
|
||||||
|
title = "Box is healthy and its sensor data reaches InfluxDB"
|
||||||
|
text = "Status, microphone and all sensor readings of this box arrived in InfluxDB in the last 15 minutes."
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "invalid_value"
|
||||||
|
severity = "critical"
|
||||||
|
title = "InfluxDB rejects every upload of this box (invalid value -inf)"
|
||||||
|
text = "The box is online and uploads, but one measurement carries -inf (the microphone records pure silence). InfluxDB refuses the whole upload with HTTP 400, so neither the status nor the sensor data arrive."
|
||||||
|
actions = [
|
||||||
|
"Run the Microphone check: a working microphone (and its switch ON) avoids the -inf value.",
|
||||||
|
"Save the session log and send it to the firmware team: the firmware must skip invalid values.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "sensors_clock"
|
||||||
|
severity = "error"
|
||||||
|
title = "Sensor data is lost: the box clock (RTC) is not running"
|
||||||
|
text = "The box sends its status and microphone level with the Internet time, but the sensor readings are timestamped by the clock module. With the clock stopped they are stamped 1 Jan 2000 and do not show up in InfluxDB."
|
||||||
|
actions = [
|
||||||
|
"Replace the RTC coin cell (inside the case), switch the box off and on, then run this check again.",
|
||||||
|
"If the Diagnosis tab still shows the clock not running with a new cell: replace the RTC module.",
|
||||||
|
"Save the session log for the firmware team.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "sensors_missing"
|
||||||
|
severity = "error"
|
||||||
|
title = "Status arrives, sensor data does not"
|
||||||
|
text = "The box is online and InfluxDB receives its status, but readings of some sensors are missing (the list is in the technician notes of the report)."
|
||||||
|
actions = [
|
||||||
|
"Check the Diagnosis tab for sensor errors; re-seat the cable of the missing sensor (inside the case).",
|
||||||
|
"If the log shows no sensor error: save the session log and send it to the firmware team.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "silent"
|
||||||
|
severity = "warning"
|
||||||
|
title = "Box is not uploading right now"
|
||||||
|
text = "InfluxDB has older data from this box, but nothing from the last 15 minutes."
|
||||||
|
actions = [
|
||||||
|
"Check that the box has Wi-Fi and Internet (Purple LED / no Internet check), then run this check again.",
|
||||||
|
"The technician notes of the report show when each kind of data arrived last - if the sensor data stopped long before the status, treat it like 'Status arrives, sensor data does not'.",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "never"
|
||||||
|
severity = "warning"
|
||||||
|
title = "No data from this box in InfluxDB"
|
||||||
|
text = "In the last 30 days InfluxDB received nothing from this box: it was never online, or its SD card has wrong database settings."
|
||||||
|
actions = [
|
||||||
|
"Run the Purple LED / no Internet check.",
|
||||||
|
"If the box is online: re-provision the SD card with the current ENV.TXT (SD card tab).",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[guide.end]]
|
||||||
|
id = "unavailable"
|
||||||
|
severity = "info"
|
||||||
|
title = "Could not check InfluxDB"
|
||||||
|
text = "The tool could not query InfluxDB from this computer (no Internet on the laptop, InfluxDB access not set up, or the box MAC not known yet)."
|
||||||
|
actions = [
|
||||||
|
"Settings > InfluxDB access: enter the read token from the firmware team.",
|
||||||
|
"Or check the box later in the service system (Stan w InfluxDB).",
|
||||||
|
]
|
||||||
@@ -280,6 +280,20 @@ severity = "warning"
|
|||||||
title = "InfluxDB refused some measurements (HTTP 400)"
|
title = "InfluxDB refused some measurements (HTTP 400)"
|
||||||
meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)."
|
meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)."
|
||||||
actions = ["Save the session log and send it to the firmware team."]
|
actions = ["Save the session log and send it to the firmware team."]
|
||||||
|
explained_by = ["influx.invalid_value"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
id = "influx.invalid_value"
|
||||||
|
# Line protocol printed by SENSOR_GATHERER / INFLUX: "-inf"/"nan" are not valid
|
||||||
|
# numbers, and one bad line makes InfluxDB reject the whole upload.
|
||||||
|
pattern = '^(?:.*format: )?(?P<measurement>[a-z0-9_]+),device=\S+ \S*=-?(?:inf|nan)\b'
|
||||||
|
severity = "critical"
|
||||||
|
title = "Box sends an invalid value (-inf/nan) - InfluxDB rejects all its data"
|
||||||
|
meaning = "A measurement contains -inf or nan (the microphone: pure silence gives -inf dB). InfluxDB refuses the whole upload with HTTP 400, so status and sensor data from this box are lost too. Firmware bug; a silent microphone triggers it."
|
||||||
|
actions = [
|
||||||
|
"Check the microphone (Microphone check): a working microphone avoids the -inf value.",
|
||||||
|
"Save the session log and send it to the firmware team: invalid values must be skipped, not uploaded.",
|
||||||
|
]
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
id = "influx.env_missing"
|
id = "influx.env_missing"
|
||||||
@@ -713,7 +727,7 @@ actions = [
|
|||||||
"Check the network has Internet (open a website on a phone on the same Wi-Fi).",
|
"Check the network has Internet (open a website on a phone on the same Wi-Fi).",
|
||||||
"Check INFLUX_URL / INFLUX_TOKEN in ENV.TXT (re-provision the SD card).",
|
"Check INFLUX_URL / INFLUX_TOKEN in ENV.TXT (re-provision the SD card).",
|
||||||
]
|
]
|
||||||
explained_by = ["kota.time_invalid", "influx.auth", "influx.not_found", "status.conn.1", "net.dns_fail", "net.tls_fail", "influx.server_error"]
|
explained_by = ["influx.invalid_value", "kota.time_invalid", "influx.auth", "influx.not_found", "status.conn.1", "net.dns_fail", "net.tls_fail", "influx.server_error"]
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
id = "kota.low_heap"
|
id = "kota.low_heap"
|
||||||
|
|||||||
@@ -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."),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import enum
|
import enum
|
||||||
import queue
|
import queue
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -50,6 +51,7 @@ class SerialManager:
|
|||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._target: _Target | None = None
|
self._target: _Target | None = None
|
||||||
|
self._open_error = ""
|
||||||
self.state = State.DISCONNECTED
|
self.state = State.DISCONNECTED
|
||||||
|
|
||||||
# ---- public API (any thread) -------------------------------------
|
# ---- public API (any thread) -------------------------------------
|
||||||
@@ -88,19 +90,35 @@ class SerialManager:
|
|||||||
info = find_port(self._target.serial_number, self._target.device)
|
info = find_port(self._target.serial_number, self._target.device)
|
||||||
if info is None:
|
if info is None:
|
||||||
return None
|
return None
|
||||||
|
posix = sys.platform != "win32"
|
||||||
# serial_for_url also accepts test URLs such as "loop://".
|
# serial_for_url also accepts test URLs such as "loop://".
|
||||||
|
# exclusive: a second tool window must not read the same port (on
|
||||||
|
# macOS/Linux both would get half of the lines). COM ports on Windows
|
||||||
|
# are always exclusive.
|
||||||
ser = serial.serial_for_url(
|
ser = serial.serial_for_url(
|
||||||
info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True
|
info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True,
|
||||||
|
**({"exclusive": True} if posix else {}),
|
||||||
)
|
)
|
||||||
# Deassert both lines before open. On USB-Serial-JTAG, RTS=1/DTR=0
|
# On USB-Serial-JTAG, RTS=1/DTR=0 holds the chip in reset and DTR=1
|
||||||
# holds the chip in reset and DTR=1 during reset selects download
|
# during reset selects download mode.
|
||||||
# mode; pyserial would otherwise assert both on open (Windows).
|
# Windows: pyserial applies both lines atomically at open, so
|
||||||
ser.dtr = False
|
# deassert both beforehand (it would otherwise assert both).
|
||||||
|
# macOS/Linux: the OS asserts both lines on open and pyserial then
|
||||||
|
# writes DTR before RTS, passing through RTS=1/DTR=0 and resetting
|
||||||
|
# the box on every connect. Drop RTS first, then DTR.
|
||||||
|
if not posix:
|
||||||
|
ser.dtr = False
|
||||||
ser.rts = False
|
ser.rts = False
|
||||||
try:
|
try:
|
||||||
ser.open()
|
ser.open()
|
||||||
except (serial.SerialException, OSError):
|
if posix:
|
||||||
|
ser.dtr = False
|
||||||
|
except (serial.SerialException, OSError) as exc:
|
||||||
|
busy = "exclusive" in str(exc).lower() or "busy" in str(exc).lower() or "access" in str(exc).lower()
|
||||||
|
self._open_error = ("The port is used by another program (another Technician Tool window?)"
|
||||||
|
if busy else "")
|
||||||
return None
|
return None
|
||||||
|
self._open_error = ""
|
||||||
self._target.device = info.device # COM number / node may change
|
self._target.device = info.device # COM number / node may change
|
||||||
return ser
|
return ser
|
||||||
|
|
||||||
@@ -134,7 +152,7 @@ class SerialManager:
|
|||||||
ser = self._open()
|
ser = self._open()
|
||||||
if ser is None:
|
if ser is None:
|
||||||
if not announced_wait:
|
if not announced_wait:
|
||||||
self._set_state(State.WAITING, "Plug in / power on the sensorbox")
|
self._set_state(State.WAITING, self._open_error or "Plug in / power on the sensorbox")
|
||||||
announced_wait = True
|
announced_wait = True
|
||||||
self._drain_cmds(None)
|
self._drain_cmds(None)
|
||||||
time.sleep(REOPEN_POLL_S)
|
time.sleep(REOPEN_POLL_S)
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Does the box's data reach InfluxDB? (read-only query, InfluxDB 2.x)
|
||||||
|
|
||||||
|
The box writes to the "sensorbox" bucket with the tag device=<MAC without
|
||||||
|
separators>. device_status and microphone use the system time, the sensor
|
||||||
|
measurements (AS7341, BME688, SEN66) are timestamped from the box's RTC -
|
||||||
|
so a box can report its status while none of its sensor data arrives
|
||||||
|
(e.g. a stopped RTC stamps readings with 1 Jan 2000).
|
||||||
|
|
||||||
|
The token must be a read token for the bucket. It comes from the
|
||||||
|
KOTA_INFLUX_TOKEN environment variable or the tool settings and is never
|
||||||
|
written to the session log or the service report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import datetime as dt
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
DEFAULT_URL = "https://influx.kota-app.com"
|
||||||
|
DEFAULT_ORG = "kota"
|
||||||
|
DEFAULT_BUCKET = "sensorbox"
|
||||||
|
LOOKBACK = "-30d"
|
||||||
|
FRESH_MIN = 15 # like SEEN_OK_MIN in the service system
|
||||||
|
FUTURE_TOLERANCE_MIN = 10 # newer than now + this: the box clock is ahead
|
||||||
|
SINCE_SLACK_S = 60 # clock difference box / laptop when counting "since the test started"
|
||||||
|
TIMEOUT_S = 20
|
||||||
|
|
||||||
|
# Sensor groups that every box must upload: name -> measurement prefixes.
|
||||||
|
SENSOR_GROUPS = {
|
||||||
|
"AS7341 (light)": ("as7341_",),
|
||||||
|
"BME688 (air)": ("bme688",),
|
||||||
|
"SEN66 (air quality)": ("sen66",),
|
||||||
|
}
|
||||||
|
STATUS = "device_status"
|
||||||
|
MIC = "microphone"
|
||||||
|
|
||||||
|
OK = "ok" # all sensor groups arrive
|
||||||
|
SENSORS_MISSING = "sensors_missing" # status/microphone arrive, (some) sensor data does not
|
||||||
|
SILENT = "silent" # data exists, but nothing recent
|
||||||
|
NEVER = "never" # no data for this box in the lookback window
|
||||||
|
UNAVAILABLE = "unavailable" # no token / no Internet / query failed
|
||||||
|
OUTCOMES = (OK, SENSORS_MISSING, SILENT, NEVER, UNAVAILABLE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InfluxConfig:
|
||||||
|
token: str
|
||||||
|
url: str = DEFAULT_URL
|
||||||
|
org: str = DEFAULT_ORG
|
||||||
|
bucket: str = DEFAULT_BUCKET
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InfluxCheck:
|
||||||
|
outcome: str
|
||||||
|
summary: str
|
||||||
|
latest: dict[str, dt.datetime] = field(default_factory=dict) # measurement -> newest point
|
||||||
|
missing: list[str] = field(default_factory=list) # sensor groups without fresh data
|
||||||
|
details: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def build_query(bucket: str, mac: str) -> str:
|
||||||
|
"""Newest point per measurement of one box (future times included)."""
|
||||||
|
return "\n".join([
|
||||||
|
f"from(bucket: {json.dumps(bucket)})",
|
||||||
|
f" |> range(start: {LOOKBACK}, stop: 2100-01-01T00:00:00Z)",
|
||||||
|
f" |> filter(fn: (r) => r.device == {json.dumps(mac)})",
|
||||||
|
" |> last()",
|
||||||
|
# Drop _value first: string and float fields cannot share a table.
|
||||||
|
' |> keep(columns: ["_measurement", "_time"])',
|
||||||
|
' |> group(columns: ["_measurement"])',
|
||||||
|
' |> max(column: "_time")',
|
||||||
|
" |> group()",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(value: str) -> dt.datetime:
|
||||||
|
"""RFC 3339 from InfluxDB; nanoseconds are cut to microseconds."""
|
||||||
|
value = value.strip().replace("Z", "+00:00")
|
||||||
|
if "." in value:
|
||||||
|
head, rest = value.split(".", 1)
|
||||||
|
frac, sign, tz = rest.partition("+") if "+" in rest else rest.partition("-")
|
||||||
|
value = f"{head}.{frac[:6]}{sign}{tz}"
|
||||||
|
return dt.datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv(text: str) -> dict[str, dt.datetime]:
|
||||||
|
"""Annotated/plain CSV from /api/v2/query -> {measurement: newest time}."""
|
||||||
|
latest: dict[str, dt.datetime] = {}
|
||||||
|
header: list[str] | None = None
|
||||||
|
for row in csv.reader(io.StringIO(text)):
|
||||||
|
if not row or not any(row):
|
||||||
|
header = None # tables are separated by an empty line
|
||||||
|
continue
|
||||||
|
if row[0].startswith("#"):
|
||||||
|
continue
|
||||||
|
if header is None:
|
||||||
|
header = row
|
||||||
|
continue
|
||||||
|
rec = dict(zip(header, row))
|
||||||
|
m, t = rec.get("_measurement"), rec.get("_time")
|
||||||
|
if m and t:
|
||||||
|
ts = parse_time(t)
|
||||||
|
if m not in latest or ts > latest[m]:
|
||||||
|
latest[m] = ts
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(now: dt.datetime, t: dt.datetime) -> str:
|
||||||
|
minutes = (now - t).total_seconds() / 60
|
||||||
|
if minutes < -1:
|
||||||
|
return f"{-minutes:.0f} min in the FUTURE"
|
||||||
|
if minutes < 90:
|
||||||
|
return f"{max(minutes, 0):.0f} min ago"
|
||||||
|
if minutes < 48 * 60:
|
||||||
|
return f"{minutes / 60:.0f} h ago"
|
||||||
|
return f"{minutes / 1440:.0f} days ago"
|
||||||
|
|
||||||
|
|
||||||
|
def classify(latest: dict[str, dt.datetime], now: dt.datetime, fresh_min: int = FRESH_MIN,
|
||||||
|
since: dt.datetime | None = None) -> InfluxCheck:
|
||||||
|
"""`since`: only data newer than this counts (the box was restarted then)."""
|
||||||
|
def newest(prefixes: tuple[str, ...]) -> dt.datetime | None:
|
||||||
|
times = [t for m, t in latest.items() if m.startswith(prefixes)]
|
||||||
|
return max(times) if times else None
|
||||||
|
|
||||||
|
def fresh(t: dt.datetime | None) -> bool:
|
||||||
|
if t is None or (now - t).total_seconds() > fresh_min * 60:
|
||||||
|
return False
|
||||||
|
return since is None or (since - t).total_seconds() <= SINCE_SLACK_S
|
||||||
|
|
||||||
|
details = []
|
||||||
|
for label, prefixes in (("Device status", (STATUS,)), ("Microphone", (MIC,)),
|
||||||
|
*SENSOR_GROUPS.items()):
|
||||||
|
t = newest(prefixes)
|
||||||
|
details.append(f"{label}: " + (f"{t:%Y-%m-%d %H:%M} UTC ({_ago(now, t)})" if t else "no data"))
|
||||||
|
if t is not None and (t - now).total_seconds() > FUTURE_TOLERANCE_MIN * 60:
|
||||||
|
details.append(f"{label}: timestamp is in the future - the box clock is wrong")
|
||||||
|
missing = [g for g, p in SENSOR_GROUPS.items() if not fresh(newest(p))]
|
||||||
|
status_t = max(filter(None, (newest((STATUS,)), newest((MIC,)))), default=None)
|
||||||
|
sensors_t = [newest(p) for p in SENSOR_GROUPS.values()]
|
||||||
|
lagging = status_t is not None and any(t is None or (status_t - t).total_seconds() > fresh_min * 60
|
||||||
|
for t in sensors_t)
|
||||||
|
if lagging:
|
||||||
|
details.append("Sensor data stopped (or never started) while the status still arrived")
|
||||||
|
|
||||||
|
if not latest:
|
||||||
|
return InfluxCheck(NEVER, f"No data from this box in InfluxDB in the last {LOOKBACK[1:]}",
|
||||||
|
latest, missing, details)
|
||||||
|
if not missing:
|
||||||
|
return InfluxCheck(OK, "Sensor data arrives in InfluxDB", latest, missing, details)
|
||||||
|
if fresh(status_t):
|
||||||
|
return InfluxCheck(SENSORS_MISSING, "Status arrives in InfluxDB, sensor data does not: "
|
||||||
|
+ ", ".join(missing), latest, missing, details)
|
||||||
|
if lagging:
|
||||||
|
# Offline now, but the history already shows the fault.
|
||||||
|
return InfluxCheck(SENSORS_MISSING, f"Nothing new in InfluxDB (last status {_ago(now, status_t)}); "
|
||||||
|
"before that the status arrived without sensor data: " + ", ".join(missing),
|
||||||
|
latest, missing, details)
|
||||||
|
if since is not None:
|
||||||
|
return InfluxCheck(SILENT, "No new data in InfluxDB since the test started", latest, missing, details)
|
||||||
|
return InfluxCheck(SILENT, f"No recent data in InfluxDB (nothing in the last {fresh_min} min)",
|
||||||
|
latest, missing, details)
|
||||||
|
|
||||||
|
|
||||||
|
def query_latest(cfg: InfluxConfig, mac: str) -> dict[str, dt.datetime]:
|
||||||
|
url = cfg.url.rstrip("/") + "/api/v2/query?" + urllib.parse.urlencode({"org": cfg.org})
|
||||||
|
req = urllib.request.Request(url, data=build_query(cfg.bucket, mac).encode(), method="POST", headers={
|
||||||
|
"Authorization": f"Token {cfg.token}",
|
||||||
|
"Content-Type": "application/vnd.flux",
|
||||||
|
"Accept": "application/csv",
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
|
||||||
|
return parse_csv(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_device(cfg: InfluxConfig | None, mac: str, now: dt.datetime | None = None,
|
||||||
|
since: dt.datetime | None = None) -> InfluxCheck:
|
||||||
|
"""Blocking; run it off the UI thread. Never raises."""
|
||||||
|
mac = "".join(c for c in (mac or "") if c.isalnum()).upper()
|
||||||
|
if cfg is None or not cfg.token:
|
||||||
|
return InfluxCheck(UNAVAILABLE, "InfluxDB access is not set up (Settings > InfluxDB access)")
|
||||||
|
if len(mac) != 12:
|
||||||
|
return InfluxCheck(UNAVAILABLE, "The box MAC address is not known yet")
|
||||||
|
try:
|
||||||
|
latest = query_latest(cfg, mac)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
why = {401: "the token was rejected", 403: "the token has no read access",
|
||||||
|
404: "organisation or bucket not found"}.get(exc.code, f"HTTP {exc.code}")
|
||||||
|
return InfluxCheck(UNAVAILABLE, f"InfluxDB query failed: {why}")
|
||||||
|
except (urllib.error.URLError, OSError) as exc:
|
||||||
|
reason = getattr(exc, "reason", exc)
|
||||||
|
return InfluxCheck(UNAVAILABLE, f"InfluxDB not reachable from this computer ({reason})")
|
||||||
|
except (ValueError, csv.Error) as exc:
|
||||||
|
return InfluxCheck(UNAVAILABLE, f"Unexpected InfluxDB answer ({exc})")
|
||||||
|
return classify(latest, now or dt.datetime.now(dt.timezone.utc), since=since)
|
||||||
@@ -33,13 +33,15 @@ CONNECTIVITY = "Problem z łącznością"
|
|||||||
_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), ("influx.invalid_value", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
|
||||||
("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
|
("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),
|
||||||
|
("tech.influx.invalid_value", SOFTWARE), ("tech.influx.sensors_clock", HARDWARE), ("tech.influx.silent", CONNECTIVITY),
|
||||||
|
("tech.influx.never", CONNECTIVITY), ("tech.influx.", CHECK),
|
||||||
# firmware 1.0.16+ (KOTA: lines, status, error IDs)
|
# 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),
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ HINTS = [
|
|||||||
|
|
||||||
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 "
|
||||||
|
"hub; USB-C only often fails). The cable must carry data.",
|
||||||
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
||||||
"<b>Connect</b>.",
|
"<b>Connect</b>.",
|
||||||
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""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, the test checklist (start-up milestones plus the
|
||||||
box reports it), firmware, the box's own self-report (KOTA lines), start-up
|
online InfluxDB check), a findings tree (root causes; their consequences as
|
||||||
milestones, a findings tree (root causes; their consequences as children;
|
children; titles only) and a wrapped detail pane for the selected finding.
|
||||||
titles only) and a wrapped detail pane for the selected finding.
|
`details` is a separate widget for the host to place: expected LED (colour +
|
||||||
|
pattern + meaning when the box reports it), firmware and the box's own
|
||||||
|
self-report (KOTA lines).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,6 +19,7 @@ from PySide6.QtWidgets import (
|
|||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QTextBrowser,
|
QTextBrowser,
|
||||||
@@ -43,13 +46,19 @@ BANNER = {
|
|||||||
}
|
}
|
||||||
_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")}
|
||||||
|
# Extra checklist rows (e.g. the InfluxDB check): state -> (mark, colour)
|
||||||
|
ROW_STATE = {"ok": ("✔", "#1b7a33"), "bad": ("✖", "#b03a2e"), "warn": ("!", "#a86400"),
|
||||||
|
"running": ("…", "#2e6da4"), "pending": ("○", "#6b6b6b"), "off": ("–", "#6b6b6b"),
|
||||||
|
"note": ("", "#444444")}
|
||||||
|
|
||||||
|
|
||||||
def _heading(text: str) -> QLabel:
|
def _heading(text: str) -> QLabel:
|
||||||
lbl = QLabel(text)
|
lbl = QLabel(text)
|
||||||
f = lbl.font()
|
f = lbl.font()
|
||||||
f.setBold(True)
|
f.setBold(True)
|
||||||
|
f.setPointSize(f.pointSize() + 1)
|
||||||
lbl.setFont(f)
|
lbl.setFont(f)
|
||||||
|
lbl.setContentsMargins(0, 6, 0, 2)
|
||||||
return lbl
|
return lbl
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +96,14 @@ class DiagnosticsPanel(QWidget):
|
|||||||
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.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||||
|
self.milestones.setWordWrap(True)
|
||||||
|
self.milestones.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||||
|
self.milestones.setStyleSheet("QListWidget { background: transparent; } QListWidget::item { padding: 2px; }")
|
||||||
|
mf = self.milestones.font()
|
||||||
|
mf.setPointSize(mf.pointSize() + 1)
|
||||||
|
self.milestones.setFont(mf)
|
||||||
|
|
||||||
self.tree = QTreeWidget()
|
self.tree = QTreeWidget()
|
||||||
self.tree.setHeaderHidden(True)
|
self.tree.setHeaderHidden(True)
|
||||||
@@ -101,22 +117,31 @@ class DiagnosticsPanel(QWidget):
|
|||||||
findings.setSizes([220, 260])
|
findings.setSizes([220, 260])
|
||||||
|
|
||||||
lay = QVBoxLayout(self)
|
lay = QVBoxLayout(self)
|
||||||
lay.setContentsMargins(4, 0, 0, 0)
|
lay.setContentsMargins(4, 0, 4, 0)
|
||||||
lay.addWidget(self.verdict)
|
lay.addWidget(self.verdict)
|
||||||
lay.addWidget(_heading("Status LED should be"))
|
lay.addWidget(_heading("Test checklist (current boot)"))
|
||||||
lay.addLayout(led_row)
|
|
||||||
lay.addWidget(_heading("Firmware"))
|
|
||||||
lay.addWidget(self.firmware)
|
|
||||||
self.qr_btn = QPushButton("Matter pairing QR code...")
|
|
||||||
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
|
|
||||||
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
|
||||||
lay.addWidget(self.box_title)
|
|
||||||
lay.addWidget(self.box)
|
|
||||||
lay.addWidget(_heading("Start-up progress (current boot)"))
|
|
||||||
lay.addWidget(self.milestones)
|
lay.addWidget(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)
|
||||||
|
|
||||||
|
# Box details: placed by the host (a separate tab).
|
||||||
|
inner = QWidget()
|
||||||
|
dl = QVBoxLayout(inner)
|
||||||
|
dl.addWidget(_heading("Status LED should be"))
|
||||||
|
dl.addLayout(led_row)
|
||||||
|
dl.addWidget(_heading("Firmware"))
|
||||||
|
dl.addWidget(self.firmware)
|
||||||
|
self.qr_btn = QPushButton("Matter pairing QR code...")
|
||||||
|
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
|
||||||
|
dl.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||||||
|
dl.addWidget(self.box_title)
|
||||||
|
dl.addWidget(self.box)
|
||||||
|
dl.addStretch(1)
|
||||||
|
self.details = QScrollArea()
|
||||||
|
self.details.setWidgetResizable(True)
|
||||||
|
self.details.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
self.details.setWidget(inner)
|
||||||
|
|
||||||
self._findings: dict[str, Finding] = {}
|
self._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.
|
||||||
@@ -126,7 +151,8 @@ class DiagnosticsPanel(QWidget):
|
|||||||
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, extra_rows: list[tuple[str, str]] = ()) -> None:
|
||||||
|
"""extra_rows: (state, text) appended to the checklist, state in ROW_STATE."""
|
||||||
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
|
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"
|
||||||
@@ -161,10 +187,20 @@ class DiagnosticsPanel(QWidget):
|
|||||||
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 "#6b6b6b")))
|
||||||
self.milestones.addItem(it)
|
self.milestones.addItem(it)
|
||||||
rows = min(max(self.milestones.count(), 1), 15)
|
for state, text in extra_rows:
|
||||||
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
|
mark, colour = ROW_STATE[state]
|
||||||
|
it = QListWidgetItem(f"{mark} {text}" if mark else text)
|
||||||
|
it.setForeground(QBrush(QColor(colour)))
|
||||||
|
if state != "note":
|
||||||
|
f = it.font()
|
||||||
|
f.setBold(True)
|
||||||
|
it.setFont(f)
|
||||||
|
it.setToolTip(text)
|
||||||
|
self.milestones.addItem(it)
|
||||||
|
height = sum(max(self.milestones.sizeHintForRow(i), 16) for i in range(self.milestones.count()))
|
||||||
|
self.milestones.setFixedHeight(min(max(height, 16), 560) + 6)
|
||||||
|
|
||||||
self._fill_tree(r)
|
self._fill_tree(r)
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ 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."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ class GuidePanel(QWidget):
|
|||||||
self.wait_bar.setMaximum(int(total))
|
self.wait_bar.setMaximum(int(total))
|
||||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||||
m, s = divmod(int(v.remaining_s), 60)
|
m, s = divmod(int(v.remaining_s), 60)
|
||||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
doing = "Checking InfluxDB" if v.step.type == "influx" else "Watching the live log"
|
||||||
|
self.wait_label.setText(f"{doing}... {m}:{s:02d} left")
|
||||||
self.note.setText(v.note)
|
self.note.setText(v.note)
|
||||||
self.note.setVisible(bool(v.note))
|
self.note.setVisible(bool(v.note))
|
||||||
self.back_btn.setEnabled(v.can_back)
|
self.back_btn.setEnabled(v.can_back)
|
||||||
@@ -212,9 +213,10 @@ class GuidePanel(QWidget):
|
|||||||
self.options.addWidget(b)
|
self.options.addWidget(b)
|
||||||
step_type = v.step.type if v.step else None
|
step_type = v.step.type if v.step else None
|
||||||
self.continue_btn.setVisible(step_type == "info")
|
self.continue_btn.setVisible(step_type == "info")
|
||||||
self.skip_btn.setVisible(step_type == "wait")
|
waits = step_type in ("wait", "influx")
|
||||||
self.wait_bar.setVisible(step_type == "wait")
|
self.skip_btn.setVisible(waits)
|
||||||
self.wait_label.setVisible(step_type == "wait")
|
self.wait_bar.setVisible(waits)
|
||||||
|
self.wait_label.setVisible(waits)
|
||||||
self.end_box.setVisible(is_end)
|
self.end_box.setVisible(is_end)
|
||||||
if is_end:
|
if is_end:
|
||||||
e = v.end
|
e = v.end
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
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
|
||||||
@@ -7,17 +12,23 @@ from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTex
|
|||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
|
QMenu,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPlainTextEdit,
|
QPlainTextEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
QTextBrowser,
|
QTextBrowser,
|
||||||
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
@@ -32,6 +43,7 @@ from ..serialio.commands import loggable
|
|||||||
from ..serialio.manager import SerialManager, State
|
from ..serialio.manager import SerialManager, State
|
||||||
from ..serialio.ports import PortInfo, list_serial_ports
|
from ..serialio.ports import PortInfo, list_serial_ports
|
||||||
from ..matter.payload import SetupPayload, payload_from_log
|
from ..matter.payload import SetupPayload, payload_from_log
|
||||||
|
from ..service import influx
|
||||||
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
|
||||||
from .cheatsheet import cheatsheet_html
|
from .cheatsheet import cheatsheet_html
|
||||||
from .diag_panel import DiagnosticsPanel
|
from .diag_panel import DiagnosticsPanel
|
||||||
@@ -42,6 +54,13 @@ from .sd_tab import SdTab
|
|||||||
from .wifi_tab import WifiTab
|
from .wifi_tab import WifiTab
|
||||||
|
|
||||||
MAX_LINES = 20000
|
MAX_LINES = 20000
|
||||||
|
TEST_INFLUX_AFTER_S = 30 # Test box: start the online check this long after the restart
|
||||||
|
INFLUX_ROW = "Sensor data in InfluxDB (online check)"
|
||||||
|
# test status strip: (background, text colour)
|
||||||
|
STRIP = {"idle": ("#eef1f4", "#333333"), "running": ("#dde9f7", "#123a63"), "ok": ("#d8f0dd", "#1b5e2a"),
|
||||||
|
"info": ("#dde9f7", "#123a63"), "warning": ("#fdebc8", "#6b4400"), "error": ("#f9dedc", "#8a2019"),
|
||||||
|
"critical": ("#f6d5d1", "#7b1e14")}
|
||||||
|
CLOUD_STATE = {"ok": "ok", "sensors_missing": "bad", "silent": "warn", "never": "warn", "unavailable": "off"}
|
||||||
# "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
|
# "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
|
||||||
LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
|
LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
|
||||||
"Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
"Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||||
@@ -58,11 +77,16 @@ COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(s: str) -> str:
|
||||||
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
class _Bridge(QObject):
|
class _Bridge(QObject):
|
||||||
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
|
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
|
||||||
|
|
||||||
line = Signal(object)
|
line = Signal(object)
|
||||||
state = Signal(object, str)
|
state = Signal(object, str)
|
||||||
|
influx = Signal(object) # InfluxCheck from the query thread
|
||||||
|
|
||||||
|
|
||||||
class SerialTab(QWidget):
|
class SerialTab(QWidget):
|
||||||
@@ -71,6 +95,7 @@ class SerialTab(QWidget):
|
|||||||
self._bridge = _Bridge()
|
self._bridge = _Bridge()
|
||||||
self._bridge.line.connect(self._on_line)
|
self._bridge.line.connect(self._on_line)
|
||||||
self._bridge.state.connect(self._on_state)
|
self._bridge.state.connect(self._on_state)
|
||||||
|
self._bridge.influx.connect(self._on_influx)
|
||||||
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
|
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
|
||||||
self._session = SessionLog()
|
self._session = SessionLog()
|
||||||
self._ports: list[PortInfo] = []
|
self._ports: list[PortInfo] = []
|
||||||
@@ -81,82 +106,127 @@ class SerialTab(QWidget):
|
|||||||
self._line_listeners: list = [] # other tabs following the live log (Wi-Fi over USB)
|
self._line_listeners: list = [] # other tabs following the live log (Wi-Fi over USB)
|
||||||
|
|
||||||
# --- controls
|
# --- controls
|
||||||
self.port_combo = QComboBox(minimumWidth=320)
|
self._settings = QSettings("KOTA", "TechnicianTool")
|
||||||
self.all_ports = QCheckBox("Show all ports")
|
self.port_combo = QComboBox(minimumWidth=360)
|
||||||
self.refresh_btn = QPushButton("Refresh")
|
self.refresh_btn = QPushButton("↻")
|
||||||
|
self.refresh_btn.setToolTip("Look for boxes again")
|
||||||
|
self.refresh_btn.setFixedWidth(40)
|
||||||
|
self.test_btn = QPushButton("▶ Test box")
|
||||||
|
self.test_btn.setToolTip("Restart the box, check its start-up and whether its sensor data reaches "
|
||||||
|
"InfluxDB (about 4 minutes)")
|
||||||
|
self.test_btn.setMinimumHeight(38)
|
||||||
|
self.test_btn.setStyleSheet(
|
||||||
|
"QPushButton { background:#1f7a3a; color:white; font-weight:bold; font-size:15px; "
|
||||||
|
"padding:4px 22px; border-radius:6px; border:none; }"
|
||||||
|
"QPushButton:hover { background:#25904a; } QPushButton:disabled { background:#9dbfa6; }")
|
||||||
self.connect_btn = QPushButton("Connect")
|
self.connect_btn = QPushButton("Connect")
|
||||||
self.reset_btn = QPushButton("Reset && capture")
|
self.connect_btn.setMinimumHeight(32)
|
||||||
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
|
|
||||||
|
# Less frequent actions live in the "More" menu.
|
||||||
|
self.more_btn = QToolButton(text="More ▾")
|
||||||
|
self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||||
|
self.more_btn.setMinimumHeight(32)
|
||||||
|
menu = QMenu(self.more_btn)
|
||||||
|
self.reset_btn = menu.addAction("Reset && capture (restart the box, no online check)")
|
||||||
|
menu.addSeparator()
|
||||||
|
self.service_btn = menu.addAction("Service report...")
|
||||||
|
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
|
||||||
|
"filled in; check it and press Save there")
|
||||||
|
self.copy_btn = menu.addAction("Copy report")
|
||||||
|
self.save_btn = menu.addAction("Save log view as...")
|
||||||
|
self.folder_btn = menu.addAction("Open logs folder")
|
||||||
|
self.analyze_btn = menu.addAction("Analyze log file...")
|
||||||
|
menu.addSeparator()
|
||||||
|
self.auto_service = menu.addAction("Open service report on disconnect")
|
||||||
|
self.auto_service.setCheckable(True)
|
||||||
|
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
|
||||||
|
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
|
||||||
|
self.all_ports = menu.addAction("Show all serial ports")
|
||||||
|
self.all_ports.setCheckable(True)
|
||||||
|
self.more_btn.setMenu(menu)
|
||||||
|
self._active_port: PortInfo | None = None
|
||||||
|
|
||||||
|
top = QHBoxLayout()
|
||||||
|
box_lbl = QLabel("<b>Box:</b>")
|
||||||
|
for w in (box_lbl, self.port_combo, self.refresh_btn):
|
||||||
|
top.addWidget(w)
|
||||||
|
top.addSpacing(12)
|
||||||
|
top.addWidget(self.test_btn)
|
||||||
|
top.addWidget(self.connect_btn)
|
||||||
|
top.addStretch(1)
|
||||||
|
top.addWidget(self.more_btn)
|
||||||
|
|
||||||
|
# Test status strip (what the test is doing / its result).
|
||||||
|
self.test_status = QLabel(wordWrap=True)
|
||||||
|
self.test_status.setMargin(8)
|
||||||
|
tf = self.test_status.font()
|
||||||
|
tf.setPointSize(tf.pointSize() + 1)
|
||||||
|
self.test_status.setFont(tf)
|
||||||
|
|
||||||
|
# --- live log (a tab on the right)
|
||||||
self.filter_combo = QComboBox()
|
self.filter_combo = QComboBox()
|
||||||
self.filter_combo.addItems(LEVEL_FILTERS)
|
self.filter_combo.addItems(LEVEL_FILTERS)
|
||||||
self.clear_btn = QPushButton("Clear view")
|
self.clear_btn = QPushButton("Clear view")
|
||||||
self.save_btn = QPushButton("Save view as...")
|
|
||||||
self.folder_btn = QPushButton("Open logs folder")
|
|
||||||
self.analyze_btn = QPushButton("Analyze log file...")
|
|
||||||
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
|
|
||||||
self.copy_btn = QPushButton("Copy report")
|
|
||||||
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
|
|
||||||
self.service_btn = QPushButton("Service report...")
|
|
||||||
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
|
|
||||||
"filled in; check it and press Save there")
|
|
||||||
self._settings = QSettings("KOTA", "TechnicianTool")
|
|
||||||
self.auto_service = QCheckBox("Open service report on disconnect")
|
|
||||||
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
|
|
||||||
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
|
|
||||||
self._active_port: PortInfo | None = None
|
|
||||||
|
|
||||||
row1 = QHBoxLayout()
|
|
||||||
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
|
||||||
self.connect_btn, self.reset_btn):
|
|
||||||
row1.addWidget(w)
|
|
||||||
row1.addStretch(1)
|
|
||||||
row2 = QHBoxLayout()
|
|
||||||
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
|
|
||||||
self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service):
|
|
||||||
row2.addWidget(w)
|
|
||||||
row2.addStretch(1)
|
|
||||||
|
|
||||||
self.view = QPlainTextEdit(readOnly=True)
|
self.view = QPlainTextEdit(readOnly=True)
|
||||||
self.view.setMaximumBlockCount(MAX_LINES)
|
self.view.setMaximumBlockCount(MAX_LINES)
|
||||||
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||||
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
|
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
|
||||||
mono.setStyleHint(QFont.StyleHint.Monospace)
|
mono.setStyleHint(QFont.StyleHint.Monospace)
|
||||||
mono.setPointSize(10)
|
mono.setPointSize(12 if sys.platform == "darwin" else 10)
|
||||||
self.view.setFont(mono)
|
self.view.setFont(mono)
|
||||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||||
|
log_page = QWidget()
|
||||||
|
ll = QVBoxLayout(log_page)
|
||||||
|
ll.setContentsMargins(0, 4, 0, 0)
|
||||||
|
lrow = QHBoxLayout()
|
||||||
|
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn):
|
||||||
|
lrow.addWidget(w)
|
||||||
|
lrow.addStretch(1)
|
||||||
|
ll.addLayout(lrow)
|
||||||
|
ll.addWidget(self.view, 1)
|
||||||
|
|
||||||
self.diag = DiagnosticsPanel()
|
self.diag = DiagnosticsPanel()
|
||||||
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
|
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
|
||||||
self.side_tabs = QTabWidget()
|
self.side_tabs = QTabWidget()
|
||||||
self.side_tabs.addTab(self.diag, "Diagnosis")
|
self.side_tabs.addTab(log_page, "Live log")
|
||||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||||
|
self.side_tabs.addTab(self.diag.details, "Box details")
|
||||||
split = QSplitter(Qt.Orientation.Horizontal)
|
split = QSplitter(Qt.Orientation.Horizontal)
|
||||||
split.addWidget(self.view)
|
split.addWidget(self.diag)
|
||||||
split.addWidget(self.side_tabs)
|
split.addWidget(self.side_tabs)
|
||||||
split.setStretchFactor(0, 3)
|
split.setStretchFactor(0, 1)
|
||||||
split.setStretchFactor(1, 2)
|
split.setStretchFactor(1, 1)
|
||||||
split.setSizes([700, 480])
|
split.setSizes([560, 620])
|
||||||
|
|
||||||
self.status = QLabel()
|
self.status = QLabel()
|
||||||
|
self.status.setStyleSheet("color:#555")
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.addLayout(row1)
|
layout.addLayout(top)
|
||||||
layout.addLayout(row2)
|
layout.addWidget(self.test_status)
|
||||||
layout.addWidget(split, 1)
|
layout.addWidget(split, 1)
|
||||||
layout.addWidget(self.status)
|
layout.addWidget(self.status)
|
||||||
|
|
||||||
# --- wiring
|
# --- wiring
|
||||||
self.refresh_btn.clicked.connect(self.refresh_ports)
|
self.refresh_btn.clicked.connect(self.refresh_ports)
|
||||||
self.all_ports.toggled.connect(self.refresh_ports)
|
self.all_ports.toggled.connect(self.refresh_ports)
|
||||||
|
self.test_btn.clicked.connect(self._test_box)
|
||||||
self.connect_btn.clicked.connect(self._toggle_connect)
|
self.connect_btn.clicked.connect(self._toggle_connect)
|
||||||
self.reset_btn.clicked.connect(self._manager.reset_board)
|
self.reset_btn.triggered.connect(self._manager.reset_board)
|
||||||
self.clear_btn.clicked.connect(self.view.clear)
|
self.clear_btn.clicked.connect(self.view.clear)
|
||||||
self.save_btn.clicked.connect(self._save_view)
|
self.save_btn.triggered.connect(self._save_view)
|
||||||
self.folder_btn.clicked.connect(self._open_folder)
|
self.folder_btn.triggered.connect(self._open_folder)
|
||||||
self.analyze_btn.clicked.connect(self._analyze_file)
|
self.analyze_btn.triggered.connect(self._analyze_file)
|
||||||
self.copy_btn.clicked.connect(self._copy_report)
|
self.copy_btn.triggered.connect(self._copy_report)
|
||||||
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
|
self.service_btn.triggered.connect(lambda: self._open_service_report(auto=False))
|
||||||
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
|
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
|
||||||
|
|
||||||
|
# Test box state: None = no test; else monotonic start time.
|
||||||
|
self._test_t0: float | None = None
|
||||||
|
self._test_since: dt.datetime | None = None # UTC; InfluxDB data must be newer
|
||||||
|
self._influx_started = False
|
||||||
|
self._cloud: influx.InfluxCheck | None = None # last InfluxDB answer
|
||||||
|
self._cloud_checking = False
|
||||||
|
|
||||||
# Report refresh is throttled; lines can arrive at >100/s.
|
# Report refresh is throttled; lines can arrive at >100/s.
|
||||||
self._diag_timer = QTimer(self, interval=400)
|
self._diag_timer = QTimer(self, interval=400)
|
||||||
self._diag_timer.timeout.connect(self._refresh_diag)
|
self._diag_timer.timeout.connect(self._refresh_diag)
|
||||||
@@ -212,8 +282,98 @@ class SerialTab(QWidget):
|
|||||||
self._diag_dirty = True
|
self._diag_dirty = True
|
||||||
self._session.start(port.mac or port.device)
|
self._session.start(port.mac or port.device)
|
||||||
self._active_port = port
|
self._active_port = port
|
||||||
|
self._cloud, self._cloud_checking = None, False
|
||||||
self._manager.connect(port)
|
self._manager.connect(port)
|
||||||
|
|
||||||
|
# ---- Test box ----------------------------------------------------------
|
||||||
|
def _test_box(self) -> None:
|
||||||
|
"""Restart the box, record its start-up, then check InfluxDB online."""
|
||||||
|
if self._manager.is_running:
|
||||||
|
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||||
|
self._engine.reset()
|
||||||
|
self._runner.cancel()
|
||||||
|
self._cloud, self._cloud_checking = None, False
|
||||||
|
self._manager.reset_board()
|
||||||
|
else:
|
||||||
|
port = self._selected_port() or next((p for p in self._ports if p.is_sensorbox), None)
|
||||||
|
if port is None:
|
||||||
|
QMessageBox.information(self, "Test box", "No sensorbox found. Check the USB cable (data cable) "
|
||||||
|
"and that the box's power switch is ON, then press ↻.")
|
||||||
|
return
|
||||||
|
self.connect_port(port, capture=True)
|
||||||
|
self._test_t0 = time.monotonic()
|
||||||
|
self._test_since = dt.datetime.now(dt.timezone.utc)
|
||||||
|
self._influx_started = False
|
||||||
|
self._test_end = None
|
||||||
|
self._diag_dirty = True
|
||||||
|
self._guide_log("Test box started: restarting the box and recording its start-up")
|
||||||
|
|
||||||
|
def _tick_test(self) -> None:
|
||||||
|
if self._test_t0 is None:
|
||||||
|
return
|
||||||
|
self._diag_dirty = True # countdowns in the strip and the checklist
|
||||||
|
if not self._manager.is_running:
|
||||||
|
self._finish_test(None, "Box disconnected - test stopped.")
|
||||||
|
return
|
||||||
|
elapsed = time.monotonic() - self._test_t0
|
||||||
|
if not self._influx_started and elapsed >= TEST_INFLUX_AFTER_S and self.is_connected:
|
||||||
|
self._influx_started = True
|
||||||
|
self._runner.start("influx") # polls, routes and records findings like a guided check
|
||||||
|
self.guide.refresh()
|
||||||
|
elif self._influx_started and (self._runner.end is not None or self._runner.guide is None):
|
||||||
|
self._finish_test(self._runner.end)
|
||||||
|
|
||||||
|
def _finish_test(self, end, text: str = "") -> None:
|
||||||
|
self._test_t0 = None
|
||||||
|
self._test_since = None
|
||||||
|
self._test_end = end
|
||||||
|
self._test_text = text
|
||||||
|
self._guide_log("Test box finished: " + (end.title if end else text or "stopped"))
|
||||||
|
|
||||||
|
def _cloud_rows(self) -> list[tuple[str, str]]:
|
||||||
|
in_check = self._runner.step is not None and self._runner.step.type == "influx"
|
||||||
|
if self.influx_config() is None:
|
||||||
|
return [("off", f"{INFLUX_ROW}: not set up - Settings > InfluxDB access")]
|
||||||
|
if in_check:
|
||||||
|
left = int(self._runner.view().remaining_s)
|
||||||
|
last = f" - last answer: {self._cloud.summary}" if self._cloud else ""
|
||||||
|
rows = [("running", f"{INFLUX_ROW}: checking... {left // 60}:{left % 60:02d} left{last}")]
|
||||||
|
elif self._cloud is not None:
|
||||||
|
rows = [(CLOUD_STATE.get(self._cloud.outcome, "warn"), f"{INFLUX_ROW}: {self._cloud.summary}")]
|
||||||
|
else:
|
||||||
|
when = (f"starts {TEST_INFLUX_AFTER_S} s after the restart" if self._test_t0 is not None
|
||||||
|
else "press Test box")
|
||||||
|
return [("pending", f"{INFLUX_ROW} - {when}")]
|
||||||
|
if self._cloud is not None:
|
||||||
|
rows += [("note", f" {d}") for d in self._cloud.details]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _update_test_strip(self) -> None:
|
||||||
|
if self._test_t0 is not None:
|
||||||
|
el = int(time.monotonic() - self._test_t0)
|
||||||
|
if self._influx_started:
|
||||||
|
doing = "checking that the data reaches InfluxDB - " + (
|
||||||
|
self._runner.view().note or "waiting for the first answer")
|
||||||
|
else:
|
||||||
|
ms = self._engine.report().milestones
|
||||||
|
done = sum(t is not None for _, _, t in ms)
|
||||||
|
doing = f"recording the start-up ({done} of {len(ms)} checklist steps done)"
|
||||||
|
key, text = "running", f"<b>Test running {el // 60}:{el % 60:02d}</b> - {_esc(doing)}"
|
||||||
|
elif getattr(self, "_test_end", None) is not None:
|
||||||
|
e = self._test_end
|
||||||
|
key = e.severity
|
||||||
|
text = (f"<b>Test finished: {_esc(e.title)}</b><br>{_esc(e.text)} "
|
||||||
|
"Details: Findings below and the <i>Guided troubleshooting</i> tab.")
|
||||||
|
elif getattr(self, "_test_text", ""):
|
||||||
|
key, text = "warning", f"<b>{_esc(self._test_text)}</b>"
|
||||||
|
else:
|
||||||
|
key = "idle"
|
||||||
|
text = ("Press <b>▶ Test box</b>: the tool restarts the box, checks its start-up (sensors, SD card, "
|
||||||
|
"Wi-Fi, clock) and then whether its sensor data reaches InfluxDB. Takes about 4 minutes.")
|
||||||
|
bg, fg = STRIP[key]
|
||||||
|
self.test_status.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
|
||||||
|
self.test_status.setText(text)
|
||||||
|
|
||||||
def matter_payload(self) -> SetupPayload:
|
def matter_payload(self) -> SetupPayload:
|
||||||
"""Pairing payload reported by this box (KOTA: matter), from its log, or the defaults."""
|
"""Pairing payload reported by this box (KOTA: matter), from its log, or the defaults."""
|
||||||
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [],
|
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [],
|
||||||
@@ -308,6 +468,7 @@ class SerialTab(QWidget):
|
|||||||
self._session.stop()
|
self._session.stop()
|
||||||
self.connect_btn.setText("Disconnect" if running else "Connect")
|
self.connect_btn.setText("Disconnect" if running else "Connect")
|
||||||
self.reset_btn.setEnabled(state == State.CONNECTED)
|
self.reset_btn.setEnabled(state == State.CONNECTED)
|
||||||
|
self.test_btn.setEnabled(state != State.WAITING)
|
||||||
self.port_combo.setEnabled(not running)
|
self.port_combo.setEnabled(not running)
|
||||||
self.all_ports.setEnabled(not running)
|
self.all_ports.setEnabled(not running)
|
||||||
self.refresh_btn.setEnabled(not running)
|
self.refresh_btn.setEnabled(not running)
|
||||||
@@ -367,9 +528,11 @@ class SerialTab(QWidget):
|
|||||||
|
|
||||||
# ---- diagnostics -------------------------------------------------
|
# ---- diagnostics -------------------------------------------------
|
||||||
def _refresh_diag(self) -> None:
|
def _refresh_diag(self) -> None:
|
||||||
|
self._tick_test()
|
||||||
if self._diag_dirty:
|
if self._diag_dirty:
|
||||||
self._diag_dirty = False
|
self._diag_dirty = False
|
||||||
self.diag.show_report(self._engine.report())
|
self.diag.show_report(self._engine.report(), self._cloud_rows())
|
||||||
|
self._update_test_strip()
|
||||||
self.guide.refresh() # countdowns and timeouts need a steady tick
|
self.guide.refresh() # countdowns and timeouts need a steady tick
|
||||||
|
|
||||||
# ---- guided troubleshooting hooks ------------------------------------
|
# ---- guided troubleshooting hooks ------------------------------------
|
||||||
@@ -377,8 +540,36 @@ class SerialTab(QWidget):
|
|||||||
if action == "reset" and self._manager.state == State.CONNECTED:
|
if action == "reset" and self._manager.state == State.CONNECTED:
|
||||||
self._manager.reset_board()
|
self._manager.reset_board()
|
||||||
return True
|
return True
|
||||||
|
if action == "influx":
|
||||||
|
cfg = self.influx_config()
|
||||||
|
mac = self._engine.mac or (self._active_port.mac if self._active_port else "") or ""
|
||||||
|
if cfg is None:
|
||||||
|
self._runner.note = "InfluxDB access is not set up: Settings > InfluxDB access."
|
||||||
|
return False
|
||||||
|
since = self._test_since
|
||||||
|
self._cloud_checking = True
|
||||||
|
threading.Thread(target=lambda: self._bridge.influx.emit(influx.check_device(cfg, mac, since=since)),
|
||||||
|
name="influx-check", daemon=True).start()
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def influx_config(self) -> influx.InfluxConfig | None:
|
||||||
|
"""Read token: KOTA_INFLUX_TOKEN, else the tool settings. None when not set up."""
|
||||||
|
token = os.environ.get("KOTA_INFLUX_TOKEN") or str(self._settings.value("influx/token", ""))
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return influx.InfluxConfig(
|
||||||
|
token=token,
|
||||||
|
url=str(self._settings.value("influx/url", influx.DEFAULT_URL)),
|
||||||
|
org=str(self._settings.value("influx/org", influx.DEFAULT_ORG)),
|
||||||
|
bucket=str(self._settings.value("influx/bucket", influx.DEFAULT_BUCKET)))
|
||||||
|
|
||||||
|
def _on_influx(self, check: influx.InfluxCheck) -> None:
|
||||||
|
self._cloud, self._cloud_checking = check, False
|
||||||
|
self._runner.influx_result(check.outcome, check.summary, check.details)
|
||||||
|
self._diag_dirty = True
|
||||||
|
self.guide.refresh()
|
||||||
|
|
||||||
def _guide_log(self, text: str) -> None:
|
def _guide_log(self, text: str) -> None:
|
||||||
# Technician answers go into the session log and the log view.
|
# Technician answers go into the session log and the log view.
|
||||||
line = host_event(text)
|
line = host_event(text)
|
||||||
@@ -447,6 +638,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.setCentralWidget(tabs)
|
self.setCentralWidget(tabs)
|
||||||
settings_menu = self.menuBar().addMenu("Settings")
|
settings_menu = self.menuBar().addMenu("Settings")
|
||||||
settings_menu.addAction("Service system address...", self._edit_service_base)
|
settings_menu.addAction("Service system address...", self._edit_service_base)
|
||||||
|
settings_menu.addAction("InfluxDB access...", self._edit_influx)
|
||||||
|
|
||||||
def _edit_service_base(self) -> None:
|
def _edit_service_base(self) -> None:
|
||||||
st = self.serial_tab
|
st = self.serial_tab
|
||||||
@@ -459,6 +651,39 @@ class MainWindow(QMainWindow):
|
|||||||
elif ok:
|
elif ok:
|
||||||
QMessageBox.warning(self, "Service system address", "The address must start with https://")
|
QMessageBox.warning(self, "Service system address", "The address must start with https://")
|
||||||
|
|
||||||
|
def _edit_influx(self) -> None:
|
||||||
|
st = self.serial_tab._settings
|
||||||
|
dlg = QDialog(self)
|
||||||
|
dlg.setWindowTitle("InfluxDB access")
|
||||||
|
form = QFormLayout(dlg)
|
||||||
|
form.addRow(QLabel("Used by the check 'Sensor data in the database'. Ask the firmware team for a "
|
||||||
|
"<b>read-only</b> token for the bucket.", wordWrap=True))
|
||||||
|
url = QLineEdit(str(st.value("influx/url", influx.DEFAULT_URL)))
|
||||||
|
org = QLineEdit(str(st.value("influx/org", influx.DEFAULT_ORG)))
|
||||||
|
bucket = QLineEdit(str(st.value("influx/bucket", influx.DEFAULT_BUCKET)))
|
||||||
|
token = QLineEdit(echoMode=QLineEdit.EchoMode.Password)
|
||||||
|
token.setPlaceholderText("stored - leave empty to keep" if st.value("influx/token", "")
|
||||||
|
else "paste the read token")
|
||||||
|
clear = QCheckBox("Remove the stored token")
|
||||||
|
for label, w in (("Address", url), ("Organisation", org), ("Bucket", bucket), ("Token", token), ("", clear)):
|
||||||
|
form.addRow(label, w)
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
|
buttons.accepted.connect(dlg.accept)
|
||||||
|
buttons.rejected.connect(dlg.reject)
|
||||||
|
form.addRow(buttons)
|
||||||
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||||
|
return
|
||||||
|
if not url.text().strip().startswith("https://"):
|
||||||
|
QMessageBox.warning(self, "InfluxDB access", "The address must start with https://")
|
||||||
|
return
|
||||||
|
st.setValue("influx/url", url.text().strip())
|
||||||
|
st.setValue("influx/org", org.text().strip() or influx.DEFAULT_ORG)
|
||||||
|
st.setValue("influx/bucket", bucket.text().strip() or influx.DEFAULT_BUCKET)
|
||||||
|
if clear.isChecked():
|
||||||
|
st.remove("influx/token")
|
||||||
|
elif token.text().strip():
|
||||||
|
st.setValue("influx/token", token.text().strip())
|
||||||
|
|
||||||
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||||
self.serial_tab.shutdown()
|
self.serial_tab.shutdown()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
@@ -242,3 +242,107 @@ def test_skip_wait_and_report_contains_technician_checks(rig):
|
|||||||
assert runner.end.id == "update_slow"
|
assert runner.end.id == "update_slow"
|
||||||
text = engine.report().to_text()
|
text = engine.report().to_text()
|
||||||
assert "Technician checks:" in text and "Update is taking very long" in text
|
assert "Technician checks:" in text and "Update is taking very long" in text
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ InfluxDB check
|
||||||
|
RTC_STOPPED = [*BOOT, "I (2945) KOTA: rtc running=0 time=2000-01-01T00:00:00"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def influx_rig(rig):
|
||||||
|
engine, runner, clock, actions, board, log = rig
|
||||||
|
return engine, runner, clock, actions, board
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_ok_ends_healthy(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
assert actions == ["influx"] and runner.view().waiting
|
||||||
|
runner.influx_result("ok", "Sensor data arrives in InfluxDB", ["Device status: 1 min ago"])
|
||||||
|
assert runner.end.id == "data_ok"
|
||||||
|
assert any("InfluxDB: Sensor data arrives" in t for t in engine.technician)
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_polls_until_sensors_arrive(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
runner.influx_result("sensors_missing", "Status arrives, sensor data does not: SEN66")
|
||||||
|
assert runner.end is None and "Checking again" in runner.view().note
|
||||||
|
clock.t += 10
|
||||||
|
runner.tick()
|
||||||
|
assert actions == ["influx"] # not yet
|
||||||
|
clock.t += 25
|
||||||
|
runner.tick()
|
||||||
|
assert actions == ["influx", "influx"] # polled again after poll_s
|
||||||
|
runner.tick()
|
||||||
|
assert actions == ["influx", "influx"] # no second query while one is pending
|
||||||
|
runner.influx_result("ok", "Sensor data arrives in InfluxDB")
|
||||||
|
assert runner.end.id == "data_ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_repeated_answer_is_noted_once(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
for _ in range(3):
|
||||||
|
runner.influx_result("silent", "No recent data in InfluxDB")
|
||||||
|
clock.t += 31
|
||||||
|
runner.tick()
|
||||||
|
assert sum("InfluxDB:" in t for t in engine.technician) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_sensors_missing_with_stopped_clock(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
board(*RTC_STOPPED)
|
||||||
|
runner.start("influx")
|
||||||
|
runner.influx_result("sensors_missing", "Status arrives, sensor data does not")
|
||||||
|
clock.t += 300 # past timeout_s
|
||||||
|
runner.tick()
|
||||||
|
assert runner.end.id == "sensors_clock"
|
||||||
|
f = engine.findings["tech.influx.sensors_clock"]
|
||||||
|
assert f.severity == Severity.ERROR and f.active
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_sensors_missing_without_log_cause(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
clock.t += 250
|
||||||
|
runner.influx_result("sensors_missing", "Status arrives, sensor data does not")
|
||||||
|
assert runner.end.id == "sensors_missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_unavailable_and_stale_results(rig):
|
||||||
|
engine, runner, clock, actions, board, log = rig
|
||||||
|
runner._on_action = lambda a: False # no token / host cannot query
|
||||||
|
runner.start("influx")
|
||||||
|
assert runner.end.id == "unavailable"
|
||||||
|
runner.influx_result("ok", "late answer") # ignored: step already left
|
||||||
|
assert runner.end.id == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_skip_uses_last_result(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
runner.start("influx")
|
||||||
|
runner.influx_result("silent", "No recent data in InfluxDB")
|
||||||
|
runner.proceed()
|
||||||
|
assert runner.end.id == "silent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_check_green_ends_with_influx_check(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
board(*BOOT)
|
||||||
|
runner.start("quick")
|
||||||
|
runner.answer("GREEN")
|
||||||
|
runner.answer("0") # heartbeat blinking
|
||||||
|
assert runner.guide.id == "influx" and actions == ["influx"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_influx_rejected_upload_is_named(influx_rig):
|
||||||
|
engine, runner, clock, actions, board = influx_rig
|
||||||
|
board(*BOOT, "microphone,device=A4CB8FC9473C dbfs=-inf,db=-inf 1790805114000000000",
|
||||||
|
"io,device=A4CB8FC9473C dbfs=-inf,db=-inf 1790805215000000000") # USB dropped characters
|
||||||
|
assert sum(k.startswith("influx.invalid_value") for k in engine.findings) == 1
|
||||||
|
runner.start("influx")
|
||||||
|
runner.influx_result("silent", "No new data in InfluxDB since the test started")
|
||||||
|
clock.t += 300
|
||||||
|
runner.tick()
|
||||||
|
assert runner.end.id == "invalid_value"
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""InfluxDB check: query text, CSV parsing, classification, failures (no network)."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
from kota_tech.diag.guides import INFLUX_OUTCOMES
|
||||||
|
from kota_tech.service import influx
|
||||||
|
|
||||||
|
NOW = dt.datetime(2026, 9, 30, 12, 0, tzinfo=dt.timezone.utc)
|
||||||
|
UTC = dt.timezone.utc
|
||||||
|
|
||||||
|
# Real answer shape of /api/v2/query (Accept: application/csv).
|
||||||
|
CSV = (
|
||||||
|
",result,table,_measurement,_time\r\n"
|
||||||
|
",_result,0,as7341_adc,2026-09-30T11:55:01Z\r\n"
|
||||||
|
",_result,0,bme688,2026-09-30T11:55:02.123456789Z\r\n"
|
||||||
|
",_result,0,device_status,2026-09-30T11:58:00Z\r\n"
|
||||||
|
",_result,0,microphone,2026-09-30T11:58:10Z\r\n"
|
||||||
|
",_result,0,sen66,2026-09-30T11:56:00Z\r\n"
|
||||||
|
"\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _t(minutes_ago):
|
||||||
|
return NOW - dt.timedelta(minutes=minutes_ago)
|
||||||
|
|
||||||
|
|
||||||
|
def test_outcomes_match_guide_runner():
|
||||||
|
assert influx.OUTCOMES == INFLUX_OUTCOMES
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_is_for_one_box_and_keeps_only_time():
|
||||||
|
q = influx.build_query("sensorbox", "A4CB8FC9473C")
|
||||||
|
assert 'from(bucket: "sensorbox")' in q and 'r.device == "A4CB8FC9473C"' in q
|
||||||
|
# _value must be dropped before grouping (string and float fields clash -> HTTP 400)
|
||||||
|
assert q.index("keep(") < q.index("group(columns")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_csv_newest_per_measurement():
|
||||||
|
latest = influx.parse_csv(CSV)
|
||||||
|
assert latest["bme688"] == dt.datetime(2026, 9, 30, 11, 55, 2, 123456, tzinfo=UTC)
|
||||||
|
assert set(latest) == {"as7341_adc", "bme688", "device_status", "microphone", "sen66"}
|
||||||
|
assert influx.parse_csv("") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_sensors_fresh_is_ok():
|
||||||
|
c = influx.classify(influx.parse_csv(CSV), NOW)
|
||||||
|
assert c.outcome == influx.OK and not c.missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_without_sensors_is_sensors_missing():
|
||||||
|
# box A4CB8FC9473C: RTC stopped, only status and microphone ever arrived
|
||||||
|
c = influx.classify({"device_status": _t(1), "microphone": _t(1)}, NOW)
|
||||||
|
assert c.outcome == influx.SENSORS_MISSING
|
||||||
|
assert c.missing == list(influx.SENSOR_GROUPS)
|
||||||
|
assert any("never started" in d for d in c.details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_sensor_missing():
|
||||||
|
latest = {"device_status": _t(1), "as7341_adc": _t(2), "bme688": _t(2), "sen66": _t(300)}
|
||||||
|
c = influx.classify(latest, NOW)
|
||||||
|
assert c.outcome == influx.SENSORS_MISSING and c.missing == ["SEN66 (air quality)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_box_whose_sensors_stopped_before_status():
|
||||||
|
# box A4CB8FC946F8: status 29 h ago, sensors 21 days ago
|
||||||
|
latest = {"device_status": _t(29 * 60), "microphone": _t(29 * 60),
|
||||||
|
"as7341_adc": _t(21 * 1440), "bme688": _t(21 * 1440), "sen66": _t(21 * 1440)}
|
||||||
|
c = influx.classify(latest, NOW)
|
||||||
|
assert c.outcome == influx.SENSORS_MISSING and "Nothing new" in c.summary
|
||||||
|
assert any("stopped" in d for d in c.details)
|
||||||
|
assert any("21 days ago" in d for d in c.details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_box_with_complete_history_is_silent():
|
||||||
|
# box A4CB8FC739EC: everything stopped together 11 days ago
|
||||||
|
latest = {"device_status": _t(11 * 1440), "microphone": _t(11 * 1440),
|
||||||
|
"as7341_adc": _t(11 * 1440 + 3), "bme688": _t(11 * 1440 + 3), "sen66": _t(11 * 1440 + 2)}
|
||||||
|
c = influx.classify(latest, NOW)
|
||||||
|
assert c.outcome == influx.SILENT
|
||||||
|
assert not any("stopped" in d for d in c.details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_is_never():
|
||||||
|
assert influx.classify({}, NOW).outcome == influx.NEVER
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_timestamps_are_flagged():
|
||||||
|
latest = {"device_status": _t(1), "microphone": _t(1), "as7341_adc": _t(-120), "bme688": _t(-120),
|
||||||
|
"sen66": _t(-120)}
|
||||||
|
c = influx.classify(latest, NOW)
|
||||||
|
assert any("future" in d for d in c.details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_device_without_token_or_mac():
|
||||||
|
assert influx.check_device(None, "A4CB8FC9473C").outcome == influx.UNAVAILABLE
|
||||||
|
cfg = influx.InfluxConfig("t")
|
||||||
|
assert influx.check_device(cfg, "").outcome == influx.UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_device_errors_never_raise(monkeypatch):
|
||||||
|
cfg = influx.InfluxConfig("secret-token")
|
||||||
|
|
||||||
|
def rejected(*_a, **_k):
|
||||||
|
raise urllib.error.HTTPError("u", 401, "Unauthorized", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(influx.urllib.request, "urlopen", rejected)
|
||||||
|
c = influx.check_device(cfg, "a4:cb:8f:c9:47:3c")
|
||||||
|
assert c.outcome == influx.UNAVAILABLE and "rejected" in c.summary
|
||||||
|
assert "secret-token" not in c.summary
|
||||||
|
|
||||||
|
def offline(*_a, **_k):
|
||||||
|
raise urllib.error.URLError("nodename nor servname provided")
|
||||||
|
|
||||||
|
monkeypatch.setattr(influx.urllib.request, "urlopen", offline)
|
||||||
|
assert "not reachable" in influx.check_device(cfg, "A4CB8FC9473C").summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_device_sends_token_header_and_normalized_mac(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return CSV.encode()
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout):
|
||||||
|
seen["req"] = req
|
||||||
|
return Resp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(influx.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
c = influx.check_device(influx.InfluxConfig("tok"), "a4:cb:8f:c9:47:3c", now=NOW)
|
||||||
|
req = seen["req"]
|
||||||
|
assert c.outcome == influx.OK
|
||||||
|
assert req.get_header("Authorization") == "Token tok"
|
||||||
|
assert "tok" not in req.full_url and req.full_url.endswith("/api/v2/query?org=kota")
|
||||||
|
assert b'"A4CB8FC9473C"' in req.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_since_ignores_data_from_before_the_test():
|
||||||
|
# healthy a few minutes ago, but nothing since the box was restarted by the test
|
||||||
|
latest = {"device_status": _t(5), "microphone": _t(5), "as7341_adc": _t(5), "bme688": _t(5), "sen66": _t(5)}
|
||||||
|
assert influx.classify(latest, NOW).outcome == influx.OK
|
||||||
|
c = influx.classify(latest, NOW, since=_t(2))
|
||||||
|
assert c.outcome == influx.SILENT and "since the test started" in c.summary
|
||||||
|
latest.update(device_status=_t(0), microphone=_t(0))
|
||||||
|
assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.SENSORS_MISSING
|
||||||
|
latest.update(as7341_adc=_t(0), bme688=_t(0), sen66=_t(2.5)) # within the clock slack
|
||||||
|
assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.OK
|
||||||
@@ -86,6 +86,46 @@ def test_hard_reset_pushes_rts_via_dtr_write(monkeypatch):
|
|||||||
assert all(c[1] is False for c in ser.calls if c[0] == "dtr")
|
assert all(c[1] is False for c in ser.calls if c[0] == "dtr")
|
||||||
|
|
||||||
|
|
||||||
|
class _PosixOpenSerial:
|
||||||
|
"""pyserial on macOS/Linux: the OS asserts both lines on open, then
|
||||||
|
pyserial writes the requested DTR, then RTS. Records every line state."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.port = args[0] if args else "fake"
|
||||||
|
self.dtr = self.rts = True # pyserial defaults
|
||||||
|
self.states = []
|
||||||
|
|
||||||
|
def __setattr__(self, name, value):
|
||||||
|
object.__setattr__(self, name, value)
|
||||||
|
if name in ("dtr", "rts") and getattr(self, "is_open", False):
|
||||||
|
self.states.append((self.dtr, self.rts))
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
want_dtr, want_rts = self.dtr, self.rts
|
||||||
|
object.__setattr__(self, "dtr", True)
|
||||||
|
object.__setattr__(self, "rts", True)
|
||||||
|
self.is_open = True
|
||||||
|
self.states.append((True, True))
|
||||||
|
self.dtr = want_dtr
|
||||||
|
self.rts = want_rts
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_on_posix_never_passes_through_reset(monkeypatch):
|
||||||
|
monkeypatch.setattr(mgr_mod.sys, "platform", "darwin")
|
||||||
|
monkeypatch.setattr(mgr_mod, "find_port", lambda sn, dev: LOOP)
|
||||||
|
opened = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mgr_mod.serial, "serial_for_url",
|
||||||
|
lambda *a, **k: opened.append(_PosixOpenSerial(*a, **k)) or opened[-1],
|
||||||
|
)
|
||||||
|
m = SerialManager(lambda ln: None, lambda s, d: None)
|
||||||
|
m._target = mgr_mod._Target(LOOP.device, None)
|
||||||
|
ser = m._open()
|
||||||
|
# (DTR, RTS) = (False, True) resets the chip on connect.
|
||||||
|
assert (False, True) not in ser.states
|
||||||
|
assert ser.states[-1] == (False, False)
|
||||||
|
|
||||||
|
|
||||||
def test_waits_when_port_missing(monkeypatch):
|
def test_waits_when_port_missing(monkeypatch):
|
||||||
monkeypatch.setattr(mgr_mod, "find_port", lambda sn, dev: None)
|
monkeypatch.setattr(mgr_mod, "find_port", lambda sn, dev: None)
|
||||||
states, lines = [], []
|
states, lines = [], []
|
||||||
|
|||||||
@@ -15,5 +15,38 @@ def test_main_window_builds_with_all_tabs():
|
|||||||
assert [tabs.tabText(i) for i in range(tabs.count())] == \
|
assert [tabs.tabText(i) for i in range(tabs.count())] == \
|
||||||
["Quick start", "Serial monitor", "Flash firmware", "Wi-Fi (USB)", "SD card"]
|
["Quick start", "Serial monitor", "Flash firmware", "Wi-Fi (USB)", "SD card"]
|
||||||
assert not w.wifi_tab.write_btn.isEnabled() # nothing connected, nothing typed
|
assert not w.wifi_tab.write_btn.isEnabled() # nothing connected, nothing typed
|
||||||
|
settings = [a.text() for m in w.menuBar().actions() for a in m.menu().actions()]
|
||||||
|
assert "InfluxDB access..." in settings
|
||||||
w.serial_tab.shutdown()
|
w.serial_tab.shutdown()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_serial_tab_checklist_and_test_strip():
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from kota_tech.core.logline import parse_line
|
||||||
|
from kota_tech.service import influx
|
||||||
|
from kota_tech.ui.main_window import INFLUX_ROW, MainWindow
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
w = MainWindow()
|
||||||
|
st = w.serial_tab
|
||||||
|
assert st.test_btn.text().endswith("Test box")
|
||||||
|
assert [st.side_tabs.tabText(i) for i in range(st.side_tabs.count())] == \
|
||||||
|
["Live log", "Guided troubleshooting", "Box details"]
|
||||||
|
st.influx_config = lambda: None # no token (never touch real settings)
|
||||||
|
rows = st._cloud_rows()
|
||||||
|
assert rows[0][0] == "off" and "not set up" in rows[0][1]
|
||||||
|
st.influx_config = lambda: influx.InfluxConfig("t") # pretend access is set up
|
||||||
|
assert st._cloud_rows()[0] == ("pending", f"{INFLUX_ROW} - press Test box")
|
||||||
|
st._on_influx(influx.InfluxCheck(influx.SENSORS_MISSING, "Status arrives, sensor data does not",
|
||||||
|
details=["SEN66 (air quality): no data"]))
|
||||||
|
rows = st._cloud_rows()
|
||||||
|
assert rows[0][0] == "bad" and rows[1][0] == "note"
|
||||||
|
st._engine.feed(parse_line("ESP-ROM:esp32s3-20210327"))
|
||||||
|
st._diag_dirty = True
|
||||||
|
st._refresh_diag()
|
||||||
|
texts = [st.diag.milestones.item(i).text() for i in range(st.diag.milestones.count())]
|
||||||
|
assert any(INFLUX_ROW in t for t in texts)
|
||||||
|
assert "Test box" in st.test_status.text()
|
||||||
|
st.shutdown()
|
||||||
|
app.processEvents()
|
||||||