8 Commits
Author SHA1 Message Date
kkallas 7a5db49ee2 Merge pull request 'Feature/influx check' (#3) from feature/influx-check into main
Reviewed-on: #3
2026-10-02 11:39:48 +02:00
KrzyszurandClaude Opus 5.5 bb933ff748 Serial monitor redesign: Test box button, InfluxDB row in the checklist
- Test box: restart + start-up capture + online InfluxDB check (data newer
  than the restart), with a status strip; checklist and findings are the
  main view, live log / guided troubleshooting / box details are tabs,
  rarely used actions in a More menu
- light palette on all systems (macOS dark mode made the colours unreadable)
- new rule influx.invalid_value: -inf from a silent microphone makes
  InfluxDB reject every upload (HTTP 400); guide end names it
- exclusive serial port on macOS/Linux: two tool windows no longer split
  the log between them
- InfluxDB check: `since` filter, neutral wording when nothing new arrives

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:06:48 +02:00
KrzyszurandClaude Opus 5.5 66cb804102 Checklist: verify that sensor data reaches InfluxDB
New guided check "Sensor data in the database (InfluxDB)", also the last
step of the Quick check on the green path. It queries InfluxDB (read
token) for the newest point per measurement of the box and tells apart:
sensor data arrives / status arrives without sensor data / offline now /
never seen / check not possible. Polls every 30 s for up to 4 minutes.
With a stopped RTC in the log it concludes that sensor readings are lost
(stamped 1 Jan 2000): box A4:CB:8F:C9:47:3C sent only device_status and
microphone.

- service/influx.py: query, CSV parsing, classification (no new deps)
- guides: new step type "influx" (host answers via influx_result)
- Settings > InfluxDB access (or KOTA_INFLUX_TOKEN); token never logged
- service status mapping for tech.influx.* findings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:06:48 +02:00
KrzyszurandClaude Opus 5.5 2b6d43eb45 macOS: do not reset the box on connect
On macOS/Linux the OS asserts DTR and RTS when the port is opened and
pyserial then writes DTR before RTS, passing through RTS=1/DTR=0 - the
USB-Serial-JTAG reset state. Every Connect restarted the box. Drop RTS
before open and DTR right after it on posix; Windows is unchanged.

Verified on macOS 27 / arm64 with box A4:CB:8F:C9:47:3C (fw 1.0.16,
build 3ae273ec): connect keeps the box running, Reset & capture still
restarts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:06:48 +02:00
kkallas 834bad2bce Merge branch 'docs/technician-guide' into main
Technician guide (Markdown + PDF) with screenshots; USB 2.0 type-A plug requirement in the guide, README and the app's hints.
2026-09-30 15:46:00 +02:00
kkallasandClaude Opus 5.5 c41fa101b6 Technician guide as PDF (docs/INSTRUKCJA_TECHNIKA.pdf)
Printable A4 version of the guide with the screenshots, linked from the README.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:39:02 +02:00
29 changed files with 1349 additions and 92 deletions

No files matched your search

+10
View File
@@ -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).
**Instrukcja krok po kroku ze zrzutami ekranu: [docs/INSTRUKCJA_TECHNIKA.md](docs/INSTRUKCJA_TECHNIKA.md) (do druku: [PDF](docs/INSTRUKCJA_TECHNIKA.pdf)).**
Box podłączaj tak, żeby po drodze była wtyczka USB 2.0 typu A (do komputera albo do huba).
## Dla technika: dwa pliki
| System | Aktualizacja | Uruchomienie |
@@ -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`).
## Dane w InfluxDB
Kontrola **Sensor data in the database (InfluxDB)** (Guided troubleshooting, także ostatni krok Quick check)
sprawdza online, czy do InfluxDB trafiają dane z czujników boxa, a nie tylko jego status. Wymaga internetu
na laptopie i jednorazowego ustawienia tokenu tylko do odczytu (od zespołu firmware):
**Settings > InfluxDB access...**. Tokenu nie zapisujemy w repozytorium.
## Port sensorboxa
- Windows: `COMx - Sensorbox (native USB) [MAC]`
+27
View File
@@ -12,3 +12,30 @@
the firmware team should bump the version for the next build.
- error_ids.csv updated from the firmware (E-CON-01).
- Test that builds the main window (catches start-up errors).
- New check **Sensor data in the database (InfluxDB)** (Guided troubleshooting; also the last step of the
Quick check on the green path): queries InfluxDB online (read-only) for the box's newest points and tells
apart "sensor data arrives", "status arrives but sensor data does not", "offline now" and "never seen".
Polls every 30 s for up to 4 minutes. With a stopped RTC in the log it concludes that the sensor readings
are lost (they are stamped 1 Jan 2000) - found on box A4:CB:8F:C9:47:3C, which reported only
device_status and microphone. The result and the per-measurement times go into the report and the
service note.
- Settings > **InfluxDB access...**: address, organisation, bucket and a read token (or the
`KOTA_INFLUX_TOKEN` environment variable). Defaults: influx.kota-app.com, org `kota`, bucket `sensorbox`.
The token is never written to the session log or the service report.
- macOS: Connect no longer restarts the box (pyserial dropped DTR before RTS, passing through the
USB-Serial-JTAG reset state). Windows unchanged.
- **Serial monitor redesigned.** One green **Test box** button: restarts the box, records the start-up and
then checks InfluxDB online (data must be newer than the restart), about 4 minutes. A status strip shows
what the test is doing and its result. The test checklist (start-up steps + the new row
"Sensor data in InfluxDB (online check)") and the findings are the main view; the live log, guided
troubleshooting and box details (LED, firmware, self-report) are tabs on the right. Rarely used actions
moved to **More**.
- Light colour scheme on every system: with macOS dark mode the status colours were unreadable.
Larger log font on macOS.
- New finding **Box sends an invalid value (-inf/nan) - InfluxDB rejects all its data**: a silent microphone
makes the firmware upload `dbfs=-inf,db=-inf`; InfluxDB then refuses the whole upload (HTTP 400), so neither
the status nor the sensor data arrive (box A4:CB:8F:C9:47:3C). The InfluxDB check names it as the cause.
- The serial port is opened exclusively on macOS/Linux: a second tool window no longer steals half of the
log lines; it reports that the port is in use.
- Start-up step "Data uploaded to InfluxDB" renamed "Upload accepted by InfluxDB (box log)" - it comes
from the box's own log, the online row checks the database.
+204
View File
@@ -0,0 +1,204 @@
# KOTA Sensorbox Technician Tool – instrukcja dla technika
Program służy do sprawdzenia sensorboxa przez USB, podłączenia go do Wi-Fi bez otwierania obudowy,
wgrania firmware i przygotowania karty SD. Działa na Windows 10/11 i macOS 13 lub nowszym.
> ⚠️ **Między boxem a komputerem musi być po drodze wtyczka USB 2.0 typu A (prostokątna).**
> Może to być kabel USB 2.0 A wpięty do komputera albo do huba – hub jest w porządku.
> Połączenie wyłącznie przez USB-C (kabel USB-C–USB-C, przejściówka USB-C bez odcinka USB 2.0 A)
> często kończy się tym, że box nie jest widoczny w systemie („Unknown USB Device”) albo restartuje się przez
> spadki napięcia. Kabel musi przenosić dane – kabel „tylko do ładowania” nie zadziała.
---
## Spis treści
1. [Instalacja (raz na komputer)](#1-instalacja-raz-na-komputer)
2. [Aktualizacja i uruchomienie](#2-aktualizacja-i-uruchomienie)
3. [Podłączenie boxa](#3-podłączenie-boxa)
4. [Diagnoza boxa – zakładka Serial monitor](#4-diagnoza-boxa--zakładka-serial-monitor)
5. [Sprawdzenie krok po kroku – Guided troubleshooting](#5-sprawdzenie-krok-po-kroku--guided-troubleshooting)
6. [Wi-Fi przez USB – zakładka Wi-Fi (USB)](#6-wi-fi-przez-usb--zakładka-wi-fi-usb)
7. [Wgrywanie firmware – zakładka Flash firmware](#7-wgrywanie-firmware--zakładka-flash-firmware)
8. [Karta SD – zakładka SD card](#8-karta-sd--zakładka-sd-card)
9. [Raport serwisowy](#9-raport-serwisowy)
10. [Co oznacza kolor diody](#10-co-oznacza-kolor-diody)
11. [Najczęstsze problemy](#11-najczęstsze-problemy)
---
## 1. Instalacja (raz na komputer)
1. Na https://gitea.kota-app.com/kkallas/kota-technician-tool otwórz **Releases** i przy najnowszej wersji
pobierz **Source Code (ZIP)**.
2. Rozpakuj do zwykłego folderu, np. `Dokumenty\KOTA\technician-tool`
(macOS: `~/Documents/KOTA/technician-tool`). **Nie** do folderu Google Drive ani OneDrive.
3. Uruchom plik aktualizacji dla swojego systemu (patrz rozdział 2). Za pierwszym razem:
- zapyta o login do Gitea (hasło lub token) – system go zapamięta,
- za Twoją zgodą zainstaluje brakujące narzędzia (Git, `uv`),
- pobierze biblioteki (1–2 minuty, potrzebny internet).
**macOS:** jeśli Finder nie chce otworzyć pliku `.command`, otwórz Terminal, wpisz `bash ` (ze spacją),
przeciągnij plik do okna Terminala i naciśnij Enter. Po pierwszej aktualizacji dwuklik zaczyna działać.
## 2. Aktualizacja i uruchomienie
W folderze narzędzia potrzebujesz tylko dwóch plików:
| System | Aktualizacja | Uruchomienie |
|---|---|---|
| Windows | `update_windows.bat` | `run_windows.bat` |
| macOS | `update_macos.command` | `run_macos.command` |
- **Aktualizacja** – uruchom przed wyjazdem do klienta albo gdy zespół ogłosi nową wersję. Pobiera tylko zmiany
i pokazuje ich listę. Wymaga internetu.
- **Uruchomienie** – otwiera program. Działa bez internetu.
Po uruchomieniu otwiera się zakładka **Quick start** ze skróconą instrukcją i tabelą kolorów diody:
![Quick start](img/01_quick_start.png)
## 3. Podłączenie boxa
1. Włącz box włącznikiem z tyłu obudowy (**ON**). Samo zasilanie z laptopa może nie wystarczyć, szczególnie
przy słabej baterii.
2. Podłącz box tak, żeby **po drodze była wtyczka USB 2.0 typu A** – do portu USB-A komputera albo do huba
(patrz ostrzeżenie na górze).
3. Na liście portów box widać jako:
- Windows: `COMx - Sensorbox (native USB) [MAC]`
- macOS: `/dev/cu.usbmodemXXXX - Sensorbox (native USB) [MAC]`
MAC (np. `14:C1:9F:57:72:48`) to numer identyfikacyjny boxa – zapisz go w raporcie.
Jeśli boxa nie ma na liście, zobacz [Najczęstsze problemy](#11-najczęstsze-problemy).
## 4. Diagnoza boxa – zakładka Serial monitor
1. Wybierz port sensorboxa i kliknij **Connect**.
2. Kliknij **Reset & capture** – box uruchomi się ponownie, a program nagra cały start.
3. Poczekaj ok. **70 sekund** – co minutę box wysyła raport stanu (health), a program uzupełnia diagnozę.
![Serial monitor i diagnoza](img/02_serial_diagnosis.png)
Po lewej jest log z boxa, po prawej panel **Diagnosis** – czytaj go od góry:
| Element | Co oznacza |
|---|---|
| **Pasek na górze** | Werdykt: zielony = wszystko działa, żółty/czerwony = główny problem. |
| **Status LED should be** | Jaki kolor i wzór powinna mieć dioda. **Porównaj z boxem** – jeśli jest inaczej, zanotuj to. |
| **Firmware** | Wersja i build firmware na boxie. |
| **Box self-report** | Co box sam o sobie zgłasza: czujniki (I2C), karta SD, zegar, Matter, mikrofon, Wi-Fi, stan co minutę. ✔ = OK, ! = uwaga, ✖ = błąd. |
| **Start-up progress** | Kolejne etapy startu z czasem w sekundach. Szare kółko = etap jeszcze nieosiągnięty. |
| **Findings** | Znalezione problemy – najpierw przyczyny, pod nimi (↳) ich skutki. Kliknij, by zobaczyć, **co to znaczy** i **co zrobić**. |
Przydatne przyciski:
- **Show: KOTA diagnostics + errors** – pokazuje w logu tylko najważniejsze linie.
- **Copy report** – kopiuje diagnozę jako tekst (np. do maila).
- **Open logs folder** – pełne logi każdej sesji (do wysłania zespołowi firmware).
## 5. Sprawdzenie krok po kroku – Guided troubleshooting
Obok zakładki **Diagnosis** jest **Guided troubleshooting**. Program proponuje sprawdzenia pasujące do
znalezionych problemów. Zacznij od **Quick check – start here**:
![Lista sprawdzeń](img/03_guided_list.png)
Program zadaje pytania (np. jaki kolor ma dioda) i na bieżąco sprawdza log boxa. Odpowiedzi trafiają do raportu:
![Pytanie o kolor diody](img/04_guided_led_question.png)
## 6. Wi-Fi przez USB – zakładka Wi-Fi (USB)
Pozwala podłączyć box do sieci Wi-Fi **bez otwierania obudowy i wyjmowania karty SD**.
Wymaga firmware z komendami USB – jeśli program zgłosi „The box does not answer USB commands”, najpierw wgraj
najnowszy firmware (rozdział 7).
1. Kliknij **Connect to the box** (jeśli box nie jest już połączony w Serial monitor).
2. Opcjonalnie **Read current Wi-Fi settings** – pokazuje zapisaną sieć (hasła nigdy nie pokazuje).
3. Wpisz **nazwę sieci (SSID)** dokładnie jak na routerze i **hasło** (8–63 znaki).
Sieć musi być **2,4 GHz** i mieć hasło.
4. Kliknij **Write to the box and connect** i potwierdź.
5. Obserwuj listę kroków. Box zapisuje ustawienia, restartuje się i łączy z siecią. Na końcu zobaczysz
**„Connected to '…' (signal … dBm)”**:
![Wi-Fi przez USB](img/06_wifi_usb.png)
- Siła sygnału: powyżej −70 dBm jest dobrze, poniżej −75 dBm połączenie może się zrywać – przestaw box lub router.
- Po zapisaniu Wi-Fi box **nie używa Matter**. Powrót do Matter: **Remove Wi-Fi (back to Matter)**.
- Jeśli box nie połączy się w 90 s, sprawdź nazwę sieci, hasło i czy to sieć 2,4 GHz. Ustawienia zostają na
boxie, a box sam próbuje dalej.
## 7. Wgrywanie firmware – zakładka Flash firmware
![Wgrywanie firmware](img/05_flash.png)
1. **Sensorbox** – wybierz port. Program pokazuje, jaki firmware jest teraz na boxie i czy wybrany plik jest
nowszy.
2. **Firmware** – wybierz plik. Lista pokazuje **datę kompilacji**, najnowszy jest na górze. Dostępne są tylko
pliki z folderu `firmware` narzędzia (aktualizuje je `update_…`).
3. **Co wgrać:**
- **App only** (zalecane przy naprawach) – wymienia tylko program. Parowanie Matter, rejestracja aktualizacji
i ustawienia **zostają**.
- **Full image** – tylko dla nowych lub całkowicie wyczyszczonych płytek. **Kasuje parowanie Matter** – box
trzeba potem sparować od nowa. Wymaga zaznaczenia potwierdzenia.
- **Back up…** – kopia całej pamięci boxa przed wgraniem (ok. 3 minuty). Zalecane przy Full image.
4. Kliknij **Start flashing**. Nie odłączaj kabla, dopóki nie pojawi się **„Success”** – program sam uruchomi
box ponownie i sprawdzi, czy działa nowy firmware.
Jeśli wgrywanie nie może się połączyć: inny port/kabel (z wtyczką USB 2.0 A po drodze), zamknij inne programy używające
portu. Ostateczność (trzeba otworzyć obudowę): przytrzymaj **BOOT**, naciśnij i puść **RESET**, puść **BOOT**,
kliknij Start jeszcze raz.
## 8. Karta SD – zakładka SD card
Karta SD jest **wewnątrz obudowy**. Ta zakładka służy do przygotowania nowej karty albo naprawy uszkodzonej.
Do zmiany samego Wi-Fi używaj zakładki **Wi-Fi (USB)** – nie trzeba wtedy otwierać obudowy.
![Karta SD](img/07_sd_card.png)
1. **Settings** – ustawienia wczytują się z pliku od zespołu (INFLUX_URL, INFLUX_TOKEN). Nie zmieniaj ich bez
potrzeby. Opcjonalnie możesz wpisać Wi-Fi (sieć i hasło).
2. Włóż kartę do czytnika w komputerze. Na liście pojawiają się tylko karty wymienne – dyski komputera nie są
pokazywane.
3. **Check card** – sprawdza kartę bez zmieniania czegokolwiek.
**Write ENV.TXT only** – zapisuje ustawienia, zostawia pomiary na karcie.
**Format card and write ENV.TXT** – kasuje całą kartę (FAT32) i zapisuje ustawienia.
4. Program sam wysuwa kartę – dopiero wtedy ją wyjmij.
## 9. Raport serwisowy
Kliknij **Service report…** w zakładce Serial monitor. Otworzy się strona boxa w systemie KOTA Serwis
z wpisaną diagnozą (werdykt, stan boxa, etapy startu, problemy z liczbą wystąpień i czasem). Sprawdź treść,
dopisz lokalizację i uwagi, kliknij **Zapisz**.
Zaznaczenie **Open service report on disconnect** otwiera raport automatycznie po rozłączeniu boxa.
## 10. Co oznacza kolor diody
| Kolor | Wzór | Znaczenie | Co zrobić |
|---|---|---|---|
| Zielony | ciągły | Wszystko OK | Nic |
| Cyan (turkusowy) | pulsuje / szybko miga | Nie sparowany w Matter / telefon łączy się | Sparuj w aplikacji Matter albo ustaw Wi-Fi przez USB |
| Biały | szybko miga / ciągły / pulsuje | Błąd karty SD lub ENV.TXT / reset fabryczny / tryb konfiguracji Wi-Fi | Sprawdź kartę SD (zakładka SD card) |
| Czerwony | 1–6 błysków | Awaria czujnika: 1 BME688, 2 SEN66, 3 AS7341, 4 mikrofon, 5 flicker, 6 test po aktualizacji | Policz błyski, sprawdź przewód tego czujnika |
| Czerwony | ciągły | Inny błąd wewnętrzny / brak pamięci | Zapisz log, wyślij zespołowi |
| Niebieski | 1 / 2 błyski | Zegar RTC nie odpowiada / zablokowana magistrala I2C | Sprawdź przewód I2C między płytkami |
| Fioletowy | ciągły / miga | Brak sieci / serwer odrzuca box (złe ENV.TXT) | Sprawdź Wi-Fi / wgraj poprawne ENV.TXT |
| Żółty | miga coraz szybciej | Pobiera aktualizację | Nie wyłączaj, poczekaj |
| Pomarańczowy | ciągły | Test po aktualizacji (~2,5 min) | Poczekaj |
| Zgaszona | – | Firmware nie działa lub brak zasilania | Sprawdź włącznik i kabel; wgraj firmware |
Mała dioda obok (heartbeat): miga raz na sekundę = online, podwójnie = offline, nie miga = firmware stoi.
## 11. Najczęstsze problemy
| Objaw | Przyczyna | Co zrobić |
|---|---|---|
| Boxa nie ma na liście portów | Brak wtyczki USB 2.0 A po drodze (samo USB-C), kabel tylko do ładowania, box wyłączony | **Połączenie z wtyczką USB 2.0 typu A** (do komputera lub huba), kabel z transmisją danych, włącznik ON, odłącz i podłącz ponownie. W Windows sprawdź Menedżer urządzeń – „Unknown USB Device” oznacza problem z kablem/portem/zasilaniem. |
| Diagnoza: „Power supply voltage drops (brown-out)”, box się restartuje | Za słabe zasilanie | Włącznik boxa ON, inny port USB-A, zasilacz; naładuj baterię |
| „The box does not answer USB commands” (Wi-Fi USB) | Stary firmware | Wgraj najnowszy firmware (App only) |
| „Clock chip was stopped at start-up” | Pusta bateria zegara RTC | Zegar ustawi się sam po połączeniu z internetem; przy powtórce wymień baterię pastylkową |
| „Box is not connected to Wi-Fi (not commissioned in Matter)” | Box nie jest sparowany | Sparuj w Matter (przycisk **Matter pairing QR code…**) albo ustaw Wi-Fi przez USB |
| Wgrywanie nie może połączyć się z boxem | Port zajęty lub zły kabel | Zamknij inne programy, inny kabel/port; ostatecznie BOOT + RESET (rozdział 7) |
Jeśli problem się powtarza: **Open logs folder** → wyślij plik sesji zespołowi firmware razem z MAC boxa.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+29
View File
@@ -1,13 +1,42 @@
import sys
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
from .ui.main_window import MainWindow
def light_palette() -> QPalette:
"""The same light look on every system. All status colours in the tool are
chosen for a light background; with macOS dark mode they were unreadable."""
p = QPalette()
roles = {
QPalette.ColorRole.Window: "#f4f5f7",
QPalette.ColorRole.WindowText: "#1d1d1f",
QPalette.ColorRole.Base: "#ffffff",
QPalette.ColorRole.AlternateBase: "#f0f2f5",
QPalette.ColorRole.Text: "#1d1d1f",
QPalette.ColorRole.Button: "#e9ebef",
QPalette.ColorRole.ButtonText: "#1d1d1f",
QPalette.ColorRole.ToolTipBase: "#ffffe8",
QPalette.ColorRole.ToolTipText: "#1d1d1f",
QPalette.ColorRole.PlaceholderText: "#8a8a8a",
QPalette.ColorRole.Highlight: "#2e6da4",
QPalette.ColorRole.HighlightedText: "#ffffff",
QPalette.ColorRole.Link: "#1f5fa8",
QPalette.ColorRole.BrightText: "#ffffff",
}
for role, colour in roles.items():
p.setColor(role, QColor(colour))
for role in (QPalette.ColorRole.WindowText, QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText):
p.setColor(QPalette.ColorGroup.Disabled, role, QColor("#9a9a9a"))
return p
def main() -> int:
app = QApplication(sys.argv)
app.setStyle("Fusion")
app.setPalette(light_palette())
win = MainWindow()
win.show()
return app.exec()
+1 -1
View File
@@ -73,7 +73,7 @@ MILESTONES = [
("wifi_connected", "Connected to Wi-Fi", False),
("time_synced", "Clock set from the Internet", False),
("clock_verified", "Clock keeps time (RTC verified)", True),
("data_uploaded", "Data uploaded to InfluxDB", False),
("data_uploaded", "Upload accepted by InfluxDB (box log)", False),
("ota_registered", "Registered for firmware updates", False),
("ota_checked", "Checked for firmware updates", False),
("ota_update_started", "Firmware update started", True),
+66 -3
View File
@@ -4,7 +4,8 @@ GUI-independent. The runner is fed the same LogLines as the diagnostics
engine, reads the engine's report to route and to skip questions the log
already answers, asks the host to perform tool actions (board reset), and
records every answer and conclusion in the engine so they appear in the
report and the session log.
report and the session log. "influx" steps ask the host to query InfluxDB
(on_action("influx")); the host answers with influx_result().
"""
from __future__ import annotations
@@ -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_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
"CYCLING": "Keeps changing / goes dark every few seconds"}
STEP_TYPES = {"led", "question", "info", "wait", "route"}
STEP_TYPES = {"led", "question", "info", "wait", "route", "influx"}
# Results of an InfluxDB check (service.influx.OUTCOMES).
INFLUX_OUTCOMES = ("ok", "sensors_missing", "silent", "never", "unavailable")
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
"error": Severity.ERROR, "critical": Severity.CRITICAL}
@@ -58,9 +61,12 @@ class Step:
skip_unless_feature: str | None = None
skip_to: str | None = None
show_qr: bool = False # show the Matter pairing QR next to the step text
outcomes: dict[str, str] = field(default_factory=dict) # influx result -> target
poll_s: float = 30.0 # influx: query again after this many seconds
def targets(self) -> list[str]:
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
t += list(self.outcomes.values())
t += [x for x in (self.default, self.next, self.on_ok, self.on_fail, self.on_timeout, self.skip_to) if x]
return t
@@ -103,6 +109,7 @@ def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
outcomes=dict(s.get("outcomes", {})), poll_s=float(s.get("poll_s", 30)),
)
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
for e in g.get("end", [])}
@@ -123,6 +130,10 @@ def _validate(guides: dict[str, Guide]) -> None:
raise ValueError(f"{g.id}.{s.id}: unknown LED colours {set(s.route) - set(LED_ANSWERS)}")
if s.type == "wait" and not (s.until or s.until_milestone or s.pass_on_timeout):
raise ValueError(f"{g.id}.{s.id}: wait step needs until/until_milestone/pass_on_timeout")
if s.type == "influx" and set(INFLUX_OUTCOMES) - set(s.outcomes):
raise ValueError(f"{g.id}.{s.id}: influx step needs outcomes {set(INFLUX_OUTCOMES) - set(s.outcomes)}")
if set(s.outcomes) - set(INFLUX_OUTCOMES):
raise ValueError(f"{g.id}.{s.id}: unknown influx outcomes {set(s.outcomes) - set(INFLUX_OUTCOMES)}")
if s.tool_action not in (None, "reset"):
raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}")
for t in s.targets():
@@ -191,6 +202,10 @@ class GuideRunner:
self._wait_started = 0.0
self._wait_boot = 0
self._wait_milestones: set[str] = set()
self._influx_pending = False
self._influx_next = 0.0
self._influx_last: str | None = None
self._influx_noted = ""
def start(self, guide_id: str) -> None:
self.cancel()
@@ -227,6 +242,28 @@ class GuideRunner:
elif s.type == "wait":
self._record(s, "technician skipped the wait")
self._finish_wait("timeout")
elif s.type == "influx":
self._record(s, "technician skipped the InfluxDB check")
self._finish_influx(self._influx_last or "unavailable")
def influx_result(self, outcome: str, summary: str, details: list[str] | tuple[str, ...] = ()) -> None:
"""Result of the host's InfluxDB query for the current influx step."""
s = self.step
if s is None or s.type != "influx" or not self._influx_pending:
return # stale answer: the technician moved on
self._influx_pending = False
self._influx_last = outcome
note = "InfluxDB: " + "; ".join([summary, *details])
if note != self._influx_noted: # polls repeat the same answer
self._influx_noted = note
self.engine.note_technician(note)
self._on_log(f"InfluxDB check: {summary}")
waited = self._clock() - self._wait_started
if outcome in ("ok", "unavailable") or waited >= s.timeout_s:
self._finish_influx(outcome)
else:
self._influx_next = self._clock() + s.poll_s
self.note = f"{summary}. Checking again in {s.poll_s:.0f} s..."
def back(self) -> None:
# Never step back into a wait: re-entering it could restart the box.
@@ -256,6 +293,11 @@ class GuideRunner:
s = self.step
if s is not None and s.type == "wait" and self._clock() - self._wait_started >= s.timeout_s:
self._finish_wait("ok" if s.pass_on_timeout else "timeout")
elif s is not None and s.type == "influx" and not self._influx_pending:
if self._clock() - self._wait_started >= s.timeout_s:
self._finish_influx(self._influx_last or "unavailable")
elif self._clock() >= self._influx_next:
self._request_influx()
# --------------------------------------------------------------- view
def view(self) -> View:
@@ -268,7 +310,7 @@ class GuideRunner:
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
elif s.type == "question":
v.options = [(str(i), o.label) for i, o in enumerate(s.options)]
elif s.type == "wait":
elif s.type in ("wait", "influx"):
v.waiting = True
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
return v
@@ -316,6 +358,27 @@ class GuideRunner:
if s.until_milestone and not s.tool_action and s.until_milestone in self.engine.milestones:
self._record(s, f"already reached: {s.until_milestone}")
self._finish_wait("ok")
if s.type == "influx":
self._wait_started = self._clock()
self._influx_last = None
self._request_influx()
def _request_influx(self) -> None:
self._influx_pending = True
if not self._on_action("influx"):
self._influx_pending = False
self._influx_last = "unavailable"
self._record(self.step, "InfluxDB check not possible from this computer")
self._finish_influx("unavailable")
def _finish_influx(self, outcome: str) -> None:
s = self.step
self._influx_pending = False
target = s.outcomes.get(outcome)
if target is None:
return
self._push()
self._go(target)
def _auto(self, s: Step, why: str, target: str | None) -> None:
self._on_log(f"technician guide: {s.id}: {why}")
+101 -8
View File
@@ -16,6 +16,9 @@
# counts as ok (used for "watch for N seconds").
# route automatic: first current root finding whose rule id is in
# `routes` decides; otherwise `default`.
# influx the tool asks InfluxDB (online) whether this box's data arrives,
# again every `poll_s` until "ok" or `timeout_s`; `outcomes` maps
# ok / sensors_missing / silent / never / unavailable -> target.
# Targets: "<step id>", "end:<end id>", "guide:<guide id>".
# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to).
# Text placeholders: {expected_led} {failing_sensors} {top_finding}
@@ -48,14 +51,7 @@ options = [
id = "green_route"
type = "route"
routes = { "rtc.clock_not_running" = "guide:clock", "mic.silent" = "guide:mic", "mic.disabled" = "guide:mic", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network" }
default = "end:healthy"
[[guide.end]]
id = "healthy"
severity = "ok"
title = "Box looks healthy"
text = "Green LED, firmware running and no problems in the log."
actions = ["If the customer still reports missing data, keep the tool connected for 5 minutes and check that 'Data uploaded to InfluxDB' gets ticked."]
default = "guide:influx"
[[guide.end]]
id = "hung"
@@ -169,6 +165,7 @@ text = "Pairing, Internet time and the first upload all worked."
actions = [
"The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.",
"It should end green.",
"Then run the check 'Sensor data in the database' - the first upload may carry only the box status.",
]
[[guide.end]]
@@ -738,3 +735,99 @@ severity = "error"
title = "Microphone records nothing"
text = "Even loud noise next to the box gives no signal, although the switch is on."
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
# ================================================================== influx
[[guide]]
id = "influx"
title = "Sensor data in the database (InfluxDB)"
summary = "Checks online that the sensor readings of this box reach InfluxDB, not only its status."
recommend = ["always"]
start = "check"
[[guide.step]]
id = "check"
type = "influx"
text = "Checking in InfluxDB that this box's data arrives: device status, microphone and the sensors (AS7341, BME688, SEN66). Keep the box switched on and online. The tool checks again every 30 s for up to 4 minutes (the air sensor needs about 2 minutes after a restart). The laptop needs Internet for this check."
timeout_s = 240
poll_s = 30
outcomes = { ok = "end:data_ok", sensors_missing = "why", silent = "why_silent", never = "why_silent", unavailable = "end:unavailable" }
[[guide.step]]
id = "why_silent"
type = "route"
routes = { "influx.invalid_value" = "end:invalid_value" }
default = "end:silent"
[[guide.step]]
id = "why"
type = "route"
routes = { "influx.invalid_value" = "end:invalid_value", "kota.rtc_stopped" = "end:sensors_clock", "rtc.clock_not_running" = "end:sensors_clock", "kota.rtc_not_ticking" = "end:sensors_clock", "kota.time_invalid" = "end:sensors_clock", "kota.rtc_read_failed" = "guide:rtc_i2c", "rtc.init_failed" = "guide:rtc_i2c", "sensor.init_failed" = "guide:sensors", "kota.sensor_missing" = "guide:sensors" }
default = "end:sensors_missing"
[[guide.end]]
id = "data_ok"
severity = "ok"
title = "Box is healthy and its sensor data reaches InfluxDB"
text = "Status, microphone and all sensor readings of this box arrived in InfluxDB in the last 15 minutes."
actions = []
[[guide.end]]
id = "invalid_value"
severity = "critical"
title = "InfluxDB rejects every upload of this box (invalid value -inf)"
text = "The box is online and uploads, but one measurement carries -inf (the microphone records pure silence). InfluxDB refuses the whole upload with HTTP 400, so neither the status nor the sensor data arrive."
actions = [
"Run the Microphone check: a working microphone (and its switch ON) avoids the -inf value.",
"Save the session log and send it to the firmware team: the firmware must skip invalid values.",
]
[[guide.end]]
id = "sensors_clock"
severity = "error"
title = "Sensor data is lost: the box clock (RTC) is not running"
text = "The box sends its status and microphone level with the Internet time, but the sensor readings are timestamped by the clock module. With the clock stopped they are stamped 1 Jan 2000 and do not show up in InfluxDB."
actions = [
"Replace the RTC coin cell (inside the case), switch the box off and on, then run this check again.",
"If the Diagnosis tab still shows the clock not running with a new cell: replace the RTC module.",
"Save the session log for the firmware team.",
]
[[guide.end]]
id = "sensors_missing"
severity = "error"
title = "Status arrives, sensor data does not"
text = "The box is online and InfluxDB receives its status, but readings of some sensors are missing (the list is in the technician notes of the report)."
actions = [
"Check the Diagnosis tab for sensor errors; re-seat the cable of the missing sensor (inside the case).",
"If the log shows no sensor error: save the session log and send it to the firmware team.",
]
[[guide.end]]
id = "silent"
severity = "warning"
title = "Box is not uploading right now"
text = "InfluxDB has older data from this box, but nothing from the last 15 minutes."
actions = [
"Check that the box has Wi-Fi and Internet (Purple LED / no Internet check), then run this check again.",
"The technician notes of the report show when each kind of data arrived last - if the sensor data stopped long before the status, treat it like 'Status arrives, sensor data does not'.",
]
[[guide.end]]
id = "never"
severity = "warning"
title = "No data from this box in InfluxDB"
text = "In the last 30 days InfluxDB received nothing from this box: it was never online, or its SD card has wrong database settings."
actions = [
"Run the Purple LED / no Internet check.",
"If the box is online: re-provision the SD card with the current ENV.TXT (SD card tab).",
]
[[guide.end]]
id = "unavailable"
severity = "info"
title = "Could not check InfluxDB"
text = "The tool could not query InfluxDB from this computer (no Internet on the laptop, InfluxDB access not set up, or the box MAC not known yet)."
actions = [
"Settings > InfluxDB access: enter the read token from the firmware team.",
"Or check the box later in the service system (Stan w InfluxDB).",
]
+15 -1
View File
@@ -280,6 +280,20 @@ severity = "warning"
title = "InfluxDB refused some measurements (HTTP 400)"
meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)."
actions = ["Save the session log and send it to the firmware team."]
explained_by = ["influx.invalid_value"]
[[rule]]
id = "influx.invalid_value"
# Line protocol printed by SENSOR_GATHERER / INFLUX: "-inf"/"nan" are not valid
# numbers, and one bad line makes InfluxDB reject the whole upload.
pattern = '^(?:.*format: )?(?P<measurement>[a-z0-9_]+),device=\S+ \S*=-?(?:inf|nan)\b'
severity = "critical"
title = "Box sends an invalid value (-inf/nan) - InfluxDB rejects all its data"
meaning = "A measurement contains -inf or nan (the microphone: pure silence gives -inf dB). InfluxDB refuses the whole upload with HTTP 400, so status and sensor data from this box are lost too. Firmware bug; a silent microphone triggers it."
actions = [
"Check the microphone (Microphone check): a working microphone avoids the -inf value.",
"Save the session log and send it to the firmware team: invalid values must be skipped, not uploaded.",
]
[[rule]]
id = "influx.env_missing"
@@ -713,7 +727,7 @@ actions = [
"Check the network has Internet (open a website on a phone on the same Wi-Fi).",
"Check INFLUX_URL / INFLUX_TOKEN in ENV.TXT (re-provision the SD card).",
]
explained_by = ["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]]
id = "kota.low_heap"
+1 -1
View File
@@ -26,7 +26,7 @@ HINTS = [
"The box did not enter flashing mode by itself. Open the case, hold the BOOT button, press and release RESET, "
"release BOOT, then press Start again."),
(re.compile(r"device reports readiness to read but returned no data|The port disappeared|device disconnected", re.I),
"The USB connection dropped. Use a short data-capable USB cable directly on the laptop (no hub) and try again."),
"The USB connection dropped. Use a data-capable cable with a USB 2.0 type-A plug on the way to the laptop (not USB-C only) and try again."),
(re.compile(r"Invalid head of packet|timed out", re.I),
"Communication errors. Try another USB cable or port."),
]
+24 -6
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import enum
import queue
import sys
import threading
import time
from collections.abc import Callable
@@ -50,6 +51,7 @@ class SerialManager:
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._target: _Target | None = None
self._open_error = ""
self.state = State.DISCONNECTED
# ---- public API (any thread) -------------------------------------
@@ -88,19 +90,35 @@ class SerialManager:
info = find_port(self._target.serial_number, self._target.device)
if info is None:
return None
posix = sys.platform != "win32"
# serial_for_url also accepts test URLs such as "loop://".
# exclusive: a second tool window must not read the same port (on
# macOS/Linux both would get half of the lines). COM ports on Windows
# are always exclusive.
ser = serial.serial_for_url(
info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True
info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True,
**({"exclusive": True} if posix else {}),
)
# Deassert both lines before open. On USB-Serial-JTAG, RTS=1/DTR=0
# holds the chip in reset and DTR=1 during reset selects download
# mode; pyserial would otherwise assert both on open (Windows).
# On USB-Serial-JTAG, RTS=1/DTR=0 holds the chip in reset and DTR=1
# during reset selects download mode.
# Windows: pyserial applies both lines atomically at open, so
# deassert both beforehand (it would otherwise assert both).
# macOS/Linux: the OS asserts both lines on open and pyserial then
# writes DTR before RTS, passing through RTS=1/DTR=0 and resetting
# the box on every connect. Drop RTS first, then DTR.
if not posix:
ser.dtr = False
ser.rts = False
try:
ser.open()
except (serial.SerialException, OSError):
if posix:
ser.dtr = False
except (serial.SerialException, OSError) as exc:
busy = "exclusive" in str(exc).lower() or "busy" in str(exc).lower() or "access" in str(exc).lower()
self._open_error = ("The port is used by another program (another Technician Tool window?)"
if busy else "")
return None
self._open_error = ""
self._target.device = info.device # COM number / node may change
return ser
@@ -134,7 +152,7 @@ class SerialManager:
ser = self._open()
if ser is None:
if not announced_wait:
self._set_state(State.WAITING, "Plug in / power on the sensorbox")
self._set_state(State.WAITING, self._open_error or "Plug in / power on the sensorbox")
announced_wait = True
self._drain_cmds(None)
time.sleep(REOPEN_POLL_S)
+202
View File
@@ -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)
+3 -1
View File
@@ -33,13 +33,15 @@ CONNECTIVITY = "Problem z łącznością"
_STATUS_BY_RULE = [
("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER),
("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE),
("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
("influx.bad_request", SOFTWARE), ("influx.invalid_value", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE),
("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE),
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
("tech.influx.invalid_value", SOFTWARE), ("tech.influx.sensors_clock", HARDWARE), ("tech.influx.silent", CONNECTIVITY),
("tech.influx.never", CONNECTIVITY), ("tech.influx.", CHECK),
# firmware 1.0.16+ (KOTA: lines, status, error IDs)
("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
("kota.low_heap", SOFTWARE),
+2 -1
View File
@@ -54,7 +54,8 @@ HINTS = [
STEPS = [
"Switch the sensorbox <b>power switch ON</b>.",
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
"Connect the box so that a <b>USB 2.0 type-A plug</b> is on the way to the laptop (into the laptop or a "
"hub; USB-C only often fails). The cable must carry data.",
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
"<b>Connect</b>.",
"Press <b>Reset &amp; capture</b> to record a full boot log from the first line.",
+55 -19
View File
@@ -1,9 +1,11 @@
"""Diagnostics side panel: renders a diag.engine.Report.
Layout: verdict banner, expected LED (colour + pattern + meaning when the
box reports it), firmware, the box's own self-report (KOTA lines), start-up
milestones, a findings tree (root causes; their consequences as children;
titles only) and a wrapped detail pane for the selected finding.
Layout: verdict banner, the test checklist (start-up milestones plus the
online InfluxDB check), a findings tree (root causes; their consequences as
children; titles only) and a wrapped detail pane for the selected finding.
`details` is a separate widget for the host to place: expected LED (colour +
pattern + meaning when the box reports it), firmware and the box's own
self-report (KOTA lines).
"""
from __future__ import annotations
@@ -17,6 +19,7 @@ from PySide6.QtWidgets import (
QListWidget,
QListWidgetItem,
QPushButton,
QScrollArea,
QSizePolicy,
QSplitter,
QTextBrowser,
@@ -43,13 +46,19 @@ BANNER = {
}
_KEY = Qt.ItemDataRole.UserRole
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
# Extra checklist rows (e.g. the InfluxDB check): state -> (mark, colour)
ROW_STATE = {"ok": ("✔", "#1b7a33"), "bad": ("✖", "#b03a2e"), "warn": ("!", "#a86400"),
"running": ("…", "#2e6da4"), "pending": ("○", "#6b6b6b"), "off": ("–", "#6b6b6b"),
"note": ("", "#444444")}
def _heading(text: str) -> QLabel:
lbl = QLabel(text)
f = lbl.font()
f.setBold(True)
f.setPointSize(f.pointSize() + 1)
lbl.setFont(f)
lbl.setContentsMargins(0, 6, 0, 2)
return lbl
@@ -87,7 +96,14 @@ class DiagnosticsPanel(QWidget):
self.milestones = QListWidget()
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
self.milestones.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.milestones.setWordWrap(True)
self.milestones.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
self.milestones.setStyleSheet("QListWidget { background: transparent; } QListWidget::item { padding: 2px; }")
mf = self.milestones.font()
mf.setPointSize(mf.pointSize() + 1)
self.milestones.setFont(mf)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
@@ -101,22 +117,31 @@ class DiagnosticsPanel(QWidget):
findings.setSizes([220, 260])
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 0, 0, 0)
lay.setContentsMargins(4, 0, 4, 0)
lay.addWidget(self.verdict)
lay.addWidget(_heading("Status LED should be"))
lay.addLayout(led_row)
lay.addWidget(_heading("Firmware"))
lay.addWidget(self.firmware)
self.qr_btn = QPushButton("Matter pairing QR code...")
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
lay.addWidget(self.box_title)
lay.addWidget(self.box)
lay.addWidget(_heading("Start-up progress (current boot)"))
lay.addWidget(_heading("Test checklist (current boot)"))
lay.addWidget(self.milestones)
lay.addWidget(_heading("Findings - root causes first; click one for details"))
lay.addWidget(findings, 1)
# Box details: placed by the host (a separate tab).
inner = QWidget()
dl = QVBoxLayout(inner)
dl.addWidget(_heading("Status LED should be"))
dl.addLayout(led_row)
dl.addWidget(_heading("Firmware"))
dl.addWidget(self.firmware)
self.qr_btn = QPushButton("Matter pairing QR code...")
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
dl.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
dl.addWidget(self.box_title)
dl.addWidget(self.box)
dl.addStretch(1)
self.details = QScrollArea()
self.details.setWidgetResizable(True)
self.details.setFrameShape(QFrame.Shape.NoFrame)
self.details.setWidget(inner)
self._findings: dict[str, Finding] = {}
self._selected: str | None = None
# key -> expanded, chosen by the user; survives the rebuild on refresh.
@@ -126,7 +151,8 @@ class DiagnosticsPanel(QWidget):
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
# ------------------------------------------------------------------
def show_report(self, r: Report) -> None:
def show_report(self, r: Report, extra_rows: list[tuple[str, str]] = ()) -> None:
"""extra_rows: (state, text) appended to the checklist, state in ROW_STATE."""
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
if r.lines == 0:
bg, fg = "#e6e6e6", "#333333"
@@ -161,10 +187,20 @@ class DiagnosticsPanel(QWidget):
done = t is not None
it = QListWidgetItem(("✔ " if done else "○ ") + label
+ (f" ({t / 1000:.1f} s)" if done else ""))
it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a")))
it.setForeground(QBrush(QColor("#1b7a33" if done else "#6b6b6b")))
self.milestones.addItem(it)
rows = min(max(self.milestones.count(), 1), 15)
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
for state, text in extra_rows:
mark, colour = ROW_STATE[state]
it = QListWidgetItem(f"{mark} {text}" if mark else text)
it.setForeground(QBrush(QColor(colour)))
if state != "note":
f = it.font()
f.setBold(True)
it.setFont(f)
it.setToolTip(text)
self.milestones.addItem(it)
height = sum(max(self.milestones.sizeHintForRow(i), 16) for i in range(self.milestones.count()))
self.milestones.setFixedHeight(min(max(height, 16), 560) + 6)
self._fill_tree(r)
+1 -1
View File
@@ -36,7 +36,7 @@ BACKUP_DIR = SESSIONS_DIR.parent / "backups"
VERIFY_TIMEOUT_MS = 45_000
HELP = (
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
"<b>If flashing cannot connect:</b> use a data-capable cable with a USB 2.0 type-A plug on the way, close "
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
)
+6 -4
View File
@@ -167,7 +167,8 @@ class GuidePanel(QWidget):
self.wait_bar.setMaximum(int(total))
self.wait_bar.setValue(int(total - v.remaining_s))
m, s = divmod(int(v.remaining_s), 60)
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
doing = "Checking InfluxDB" if v.step.type == "influx" else "Watching the live log"
self.wait_label.setText(f"{doing}... {m}:{s:02d} left")
self.note.setText(v.note)
self.note.setVisible(bool(v.note))
self.back_btn.setEnabled(v.can_back)
@@ -212,9 +213,10 @@ class GuidePanel(QWidget):
self.options.addWidget(b)
step_type = v.step.type if v.step else None
self.continue_btn.setVisible(step_type == "info")
self.skip_btn.setVisible(step_type == "wait")
self.wait_bar.setVisible(step_type == "wait")
self.wait_label.setVisible(step_type == "wait")
waits = step_type in ("wait", "influx")
self.skip_btn.setVisible(waits)
self.wait_bar.setVisible(waits)
self.wait_label.setVisible(waits)
self.end_box.setVisible(is_end)
if is_end:
e = v.end
+271 -46
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import datetime as dt
import os
import sys
import threading
import time
from pathlib import Path
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
@@ -7,17 +12,23 @@ from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTex
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSplitter,
QTabWidget,
QTextBrowser,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -32,6 +43,7 @@ from ..serialio.commands import loggable
from ..serialio.manager import SerialManager, State
from ..serialio.ports import PortInfo, list_serial_ports
from ..matter.payload import SetupPayload, payload_from_log
from ..service import influx
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
from .cheatsheet import cheatsheet_html
from .diag_panel import DiagnosticsPanel
@@ -42,6 +54,13 @@ from .sd_tab import SdTab
from .wifi_tab import WifiTab
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.
LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
"Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
@@ -58,11 +77,16 @@ COLORS = {
}
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class _Bridge(QObject):
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
line = Signal(object)
state = Signal(object, str)
influx = Signal(object) # InfluxCheck from the query thread
class SerialTab(QWidget):
@@ -71,6 +95,7 @@ class SerialTab(QWidget):
self._bridge = _Bridge()
self._bridge.line.connect(self._on_line)
self._bridge.state.connect(self._on_state)
self._bridge.influx.connect(self._on_influx)
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
self._session = SessionLog()
self._ports: list[PortInfo] = []
@@ -81,82 +106,127 @@ class SerialTab(QWidget):
self._line_listeners: list = [] # other tabs following the live log (Wi-Fi over USB)
# --- controls
self.port_combo = QComboBox(minimumWidth=320)
self.all_ports = QCheckBox("Show all ports")
self.refresh_btn = QPushButton("Refresh")
self._settings = QSettings("KOTA", "TechnicianTool")
self.port_combo = QComboBox(minimumWidth=360)
self.refresh_btn = QPushButton("↻")
self.refresh_btn.setToolTip("Look for boxes again")
self.refresh_btn.setFixedWidth(40)
self.test_btn = QPushButton("▶ Test box")
self.test_btn.setToolTip("Restart the box, check its start-up and whether its sensor data reaches "
"InfluxDB (about 4 minutes)")
self.test_btn.setMinimumHeight(38)
self.test_btn.setStyleSheet(
"QPushButton { background:#1f7a3a; color:white; font-weight:bold; font-size:15px; "
"padding:4px 22px; border-radius:6px; border:none; }"
"QPushButton:hover { background:#25904a; } QPushButton:disabled { background:#9dbfa6; }")
self.connect_btn = QPushButton("Connect")
self.reset_btn = QPushButton("Reset && capture")
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
self.connect_btn.setMinimumHeight(32)
# Less frequent actions live in the "More" menu.
self.more_btn = QToolButton(text="More ▾")
self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.more_btn.setMinimumHeight(32)
menu = QMenu(self.more_btn)
self.reset_btn = menu.addAction("Reset && capture (restart the box, no online check)")
menu.addSeparator()
self.service_btn = menu.addAction("Service report...")
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
"filled in; check it and press Save there")
self.copy_btn = menu.addAction("Copy report")
self.save_btn = menu.addAction("Save log view as...")
self.folder_btn = menu.addAction("Open logs folder")
self.analyze_btn = menu.addAction("Analyze log file...")
menu.addSeparator()
self.auto_service = menu.addAction("Open service report on disconnect")
self.auto_service.setCheckable(True)
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
self.all_ports = menu.addAction("Show all serial ports")
self.all_ports.setCheckable(True)
self.more_btn.setMenu(menu)
self._active_port: PortInfo | None = None
top = QHBoxLayout()
box_lbl = QLabel("<b>Box:</b>")
for w in (box_lbl, self.port_combo, self.refresh_btn):
top.addWidget(w)
top.addSpacing(12)
top.addWidget(self.test_btn)
top.addWidget(self.connect_btn)
top.addStretch(1)
top.addWidget(self.more_btn)
# Test status strip (what the test is doing / its result).
self.test_status = QLabel(wordWrap=True)
self.test_status.setMargin(8)
tf = self.test_status.font()
tf.setPointSize(tf.pointSize() + 1)
self.test_status.setFont(tf)
# --- live log (a tab on the right)
self.filter_combo = QComboBox()
self.filter_combo.addItems(LEVEL_FILTERS)
self.clear_btn = QPushButton("Clear view")
self.save_btn = QPushButton("Save view as...")
self.folder_btn = QPushButton("Open logs folder")
self.analyze_btn = QPushButton("Analyze log file...")
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
self.copy_btn = QPushButton("Copy report")
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
self.service_btn = QPushButton("Service report...")
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
"filled in; check it and press Save there")
self._settings = QSettings("KOTA", "TechnicianTool")
self.auto_service = QCheckBox("Open service report on disconnect")
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
self._active_port: PortInfo | None = None
row1 = QHBoxLayout()
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
self.connect_btn, self.reset_btn):
row1.addWidget(w)
row1.addStretch(1)
row2 = QHBoxLayout()
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service):
row2.addWidget(w)
row2.addStretch(1)
self.view = QPlainTextEdit(readOnly=True)
self.view.setMaximumBlockCount(MAX_LINES)
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
mono.setStyleHint(QFont.StyleHint.Monospace)
mono.setPointSize(10)
mono.setPointSize(12 if sys.platform == "darwin" else 10)
self.view.setFont(mono)
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
log_page = QWidget()
ll = QVBoxLayout(log_page)
ll.setContentsMargins(0, 4, 0, 0)
lrow = QHBoxLayout()
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn):
lrow.addWidget(w)
lrow.addStretch(1)
ll.addLayout(lrow)
ll.addWidget(self.view, 1)
self.diag = DiagnosticsPanel()
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
self.side_tabs = QTabWidget()
self.side_tabs.addTab(self.diag, "Diagnosis")
self.side_tabs.addTab(log_page, "Live log")
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
self.side_tabs.addTab(self.diag.details, "Box details")
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(self.view)
split.addWidget(self.diag)
split.addWidget(self.side_tabs)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 2)
split.setSizes([700, 480])
split.setStretchFactor(0, 1)
split.setStretchFactor(1, 1)
split.setSizes([560, 620])
self.status = QLabel()
self.status.setStyleSheet("color:#555")
layout = QVBoxLayout(self)
layout.addLayout(row1)
layout.addLayout(row2)
layout.addLayout(top)
layout.addWidget(self.test_status)
layout.addWidget(split, 1)
layout.addWidget(self.status)
# --- wiring
self.refresh_btn.clicked.connect(self.refresh_ports)
self.all_ports.toggled.connect(self.refresh_ports)
self.test_btn.clicked.connect(self._test_box)
self.connect_btn.clicked.connect(self._toggle_connect)
self.reset_btn.clicked.connect(self._manager.reset_board)
self.reset_btn.triggered.connect(self._manager.reset_board)
self.clear_btn.clicked.connect(self.view.clear)
self.save_btn.clicked.connect(self._save_view)
self.folder_btn.clicked.connect(self._open_folder)
self.analyze_btn.clicked.connect(self._analyze_file)
self.copy_btn.clicked.connect(self._copy_report)
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
self.save_btn.triggered.connect(self._save_view)
self.folder_btn.triggered.connect(self._open_folder)
self.analyze_btn.triggered.connect(self._analyze_file)
self.copy_btn.triggered.connect(self._copy_report)
self.service_btn.triggered.connect(lambda: self._open_service_report(auto=False))
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
# Test box state: None = no test; else monotonic start time.
self._test_t0: float | None = None
self._test_since: dt.datetime | None = None # UTC; InfluxDB data must be newer
self._influx_started = False
self._cloud: influx.InfluxCheck | None = None # last InfluxDB answer
self._cloud_checking = False
# Report refresh is throttled; lines can arrive at >100/s.
self._diag_timer = QTimer(self, interval=400)
self._diag_timer.timeout.connect(self._refresh_diag)
@@ -212,8 +282,98 @@ class SerialTab(QWidget):
self._diag_dirty = True
self._session.start(port.mac or port.device)
self._active_port = port
self._cloud, self._cloud_checking = None, False
self._manager.connect(port)
# ---- Test box ----------------------------------------------------------
def _test_box(self) -> None:
"""Restart the box, record its start-up, then check InfluxDB online."""
if self._manager.is_running:
self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine.reset()
self._runner.cancel()
self._cloud, self._cloud_checking = None, False
self._manager.reset_board()
else:
port = self._selected_port() or next((p for p in self._ports if p.is_sensorbox), None)
if port is None:
QMessageBox.information(self, "Test box", "No sensorbox found. Check the USB cable (data cable) "
"and that the box's power switch is ON, then press ↻.")
return
self.connect_port(port, capture=True)
self._test_t0 = time.monotonic()
self._test_since = dt.datetime.now(dt.timezone.utc)
self._influx_started = False
self._test_end = None
self._diag_dirty = True
self._guide_log("Test box started: restarting the box and recording its start-up")
def _tick_test(self) -> None:
if self._test_t0 is None:
return
self._diag_dirty = True # countdowns in the strip and the checklist
if not self._manager.is_running:
self._finish_test(None, "Box disconnected - test stopped.")
return
elapsed = time.monotonic() - self._test_t0
if not self._influx_started and elapsed >= TEST_INFLUX_AFTER_S and self.is_connected:
self._influx_started = True
self._runner.start("influx") # polls, routes and records findings like a guided check
self.guide.refresh()
elif self._influx_started and (self._runner.end is not None or self._runner.guide is None):
self._finish_test(self._runner.end)
def _finish_test(self, end, text: str = "") -> None:
self._test_t0 = None
self._test_since = None
self._test_end = end
self._test_text = text
self._guide_log("Test box finished: " + (end.title if end else text or "stopped"))
def _cloud_rows(self) -> list[tuple[str, str]]:
in_check = self._runner.step is not None and self._runner.step.type == "influx"
if self.influx_config() is None:
return [("off", f"{INFLUX_ROW}: not set up - Settings > InfluxDB access")]
if in_check:
left = int(self._runner.view().remaining_s)
last = f" - last answer: {self._cloud.summary}" if self._cloud else ""
rows = [("running", f"{INFLUX_ROW}: checking... {left // 60}:{left % 60:02d} left{last}")]
elif self._cloud is not None:
rows = [(CLOUD_STATE.get(self._cloud.outcome, "warn"), f"{INFLUX_ROW}: {self._cloud.summary}")]
else:
when = (f"starts {TEST_INFLUX_AFTER_S} s after the restart" if self._test_t0 is not None
else "press Test box")
return [("pending", f"{INFLUX_ROW} - {when}")]
if self._cloud is not None:
rows += [("note", f" {d}") for d in self._cloud.details]
return rows
def _update_test_strip(self) -> None:
if self._test_t0 is not None:
el = int(time.monotonic() - self._test_t0)
if self._influx_started:
doing = "checking that the data reaches InfluxDB - " + (
self._runner.view().note or "waiting for the first answer")
else:
ms = self._engine.report().milestones
done = sum(t is not None for _, _, t in ms)
doing = f"recording the start-up ({done} of {len(ms)} checklist steps done)"
key, text = "running", f"<b>Test running {el // 60}:{el % 60:02d}</b> - {_esc(doing)}"
elif getattr(self, "_test_end", None) is not None:
e = self._test_end
key = e.severity
text = (f"<b>Test finished: {_esc(e.title)}</b><br>{_esc(e.text)} "
"Details: Findings below and the <i>Guided troubleshooting</i> tab.")
elif getattr(self, "_test_text", ""):
key, text = "warning", f"<b>{_esc(self._test_text)}</b>"
else:
key = "idle"
text = ("Press <b>▶ Test box</b>: the tool restarts the box, checks its start-up (sensors, SD card, "
"Wi-Fi, clock) and then whether its sensor data reaches InfluxDB. Takes about 4 minutes.")
bg, fg = STRIP[key]
self.test_status.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
self.test_status.setText(text)
def matter_payload(self) -> SetupPayload:
"""Pairing payload 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 [],
@@ -308,6 +468,7 @@ class SerialTab(QWidget):
self._session.stop()
self.connect_btn.setText("Disconnect" if running else "Connect")
self.reset_btn.setEnabled(state == State.CONNECTED)
self.test_btn.setEnabled(state != State.WAITING)
self.port_combo.setEnabled(not running)
self.all_ports.setEnabled(not running)
self.refresh_btn.setEnabled(not running)
@@ -367,9 +528,11 @@ class SerialTab(QWidget):
# ---- diagnostics -------------------------------------------------
def _refresh_diag(self) -> None:
self._tick_test()
if self._diag_dirty:
self._diag_dirty = False
self.diag.show_report(self._engine.report())
self.diag.show_report(self._engine.report(), self._cloud_rows())
self._update_test_strip()
self.guide.refresh() # countdowns and timeouts need a steady tick
# ---- guided troubleshooting hooks ------------------------------------
@@ -377,7 +540,35 @@ class SerialTab(QWidget):
if action == "reset" and self._manager.state == State.CONNECTED:
self._manager.reset_board()
return True
if action == "influx":
cfg = self.influx_config()
mac = self._engine.mac or (self._active_port.mac if self._active_port else "") or ""
if cfg is None:
self._runner.note = "InfluxDB access is not set up: Settings > InfluxDB access."
return False
since = self._test_since
self._cloud_checking = True
threading.Thread(target=lambda: self._bridge.influx.emit(influx.check_device(cfg, mac, since=since)),
name="influx-check", daemon=True).start()
return True
return False
def influx_config(self) -> influx.InfluxConfig | None:
"""Read token: KOTA_INFLUX_TOKEN, else the tool settings. None when not set up."""
token = os.environ.get("KOTA_INFLUX_TOKEN") or str(self._settings.value("influx/token", ""))
if not token:
return None
return influx.InfluxConfig(
token=token,
url=str(self._settings.value("influx/url", influx.DEFAULT_URL)),
org=str(self._settings.value("influx/org", influx.DEFAULT_ORG)),
bucket=str(self._settings.value("influx/bucket", influx.DEFAULT_BUCKET)))
def _on_influx(self, check: influx.InfluxCheck) -> None:
self._cloud, self._cloud_checking = check, False
self._runner.influx_result(check.outcome, check.summary, check.details)
self._diag_dirty = True
self.guide.refresh()
def _guide_log(self, text: str) -> None:
# Technician answers go into the session log and the log view.
@@ -447,6 +638,7 @@ class MainWindow(QMainWindow):
self.setCentralWidget(tabs)
settings_menu = self.menuBar().addMenu("Settings")
settings_menu.addAction("Service system address...", self._edit_service_base)
settings_menu.addAction("InfluxDB access...", self._edit_influx)
def _edit_service_base(self) -> None:
st = self.serial_tab
@@ -459,6 +651,39 @@ class MainWindow(QMainWindow):
elif ok:
QMessageBox.warning(self, "Service system address", "The address must start with https://")
def _edit_influx(self) -> None:
st = self.serial_tab._settings
dlg = QDialog(self)
dlg.setWindowTitle("InfluxDB access")
form = QFormLayout(dlg)
form.addRow(QLabel("Used by the check 'Sensor data in the database'. Ask the firmware team for a "
"<b>read-only</b> token for the bucket.", wordWrap=True))
url = QLineEdit(str(st.value("influx/url", influx.DEFAULT_URL)))
org = QLineEdit(str(st.value("influx/org", influx.DEFAULT_ORG)))
bucket = QLineEdit(str(st.value("influx/bucket", influx.DEFAULT_BUCKET)))
token = QLineEdit(echoMode=QLineEdit.EchoMode.Password)
token.setPlaceholderText("stored - leave empty to keep" if st.value("influx/token", "")
else "paste the read token")
clear = QCheckBox("Remove the stored token")
for label, w in (("Address", url), ("Organisation", org), ("Bucket", bucket), ("Token", token), ("", clear)):
form.addRow(label, w)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(dlg.accept)
buttons.rejected.connect(dlg.reject)
form.addRow(buttons)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
if not url.text().strip().startswith("https://"):
QMessageBox.warning(self, "InfluxDB access", "The address must start with https://")
return
st.setValue("influx/url", url.text().strip())
st.setValue("influx/org", org.text().strip() or influx.DEFAULT_ORG)
st.setValue("influx/bucket", bucket.text().strip() or influx.DEFAULT_BUCKET)
if clear.isChecked():
st.remove("influx/token")
elif token.text().strip():
st.setValue("influx/token", token.text().strip())
def closeEvent(self, event): # noqa: N802 (Qt naming)
self.serial_tab.shutdown()
super().closeEvent(event)
+104
View File
@@ -242,3 +242,107 @@ def test_skip_wait_and_report_contains_technician_checks(rig):
assert runner.end.id == "update_slow"
text = engine.report().to_text()
assert "Technician checks:" in text and "Update is taking very long" in text
# ------------------------------------------------------------ InfluxDB check
RTC_STOPPED = [*BOOT, "I (2945) KOTA: rtc running=0 time=2000-01-01T00:00:00"]
@pytest.fixture
def influx_rig(rig):
engine, runner, clock, actions, board, log = rig
return engine, runner, clock, actions, board
def test_influx_ok_ends_healthy(influx_rig):
engine, runner, clock, actions, board = influx_rig
runner.start("influx")
assert actions == ["influx"] and runner.view().waiting
runner.influx_result("ok", "Sensor data arrives in InfluxDB", ["Device status: 1 min ago"])
assert runner.end.id == "data_ok"
assert any("InfluxDB: Sensor data arrives" in t for t in engine.technician)
def test_influx_polls_until_sensors_arrive(influx_rig):
engine, runner, clock, actions, board = influx_rig
runner.start("influx")
runner.influx_result("sensors_missing", "Status arrives, sensor data does not: SEN66")
assert runner.end is None and "Checking again" in runner.view().note
clock.t += 10
runner.tick()
assert actions == ["influx"] # not yet
clock.t += 25
runner.tick()
assert actions == ["influx", "influx"] # polled again after poll_s
runner.tick()
assert actions == ["influx", "influx"] # no second query while one is pending
runner.influx_result("ok", "Sensor data arrives in InfluxDB")
assert runner.end.id == "data_ok"
def test_influx_repeated_answer_is_noted_once(influx_rig):
engine, runner, clock, actions, board = influx_rig
runner.start("influx")
for _ in range(3):
runner.influx_result("silent", "No recent data in InfluxDB")
clock.t += 31
runner.tick()
assert sum("InfluxDB:" in t for t in engine.technician) == 1
def test_influx_sensors_missing_with_stopped_clock(influx_rig):
engine, runner, clock, actions, board = influx_rig
board(*RTC_STOPPED)
runner.start("influx")
runner.influx_result("sensors_missing", "Status arrives, sensor data does not")
clock.t += 300 # past timeout_s
runner.tick()
assert runner.end.id == "sensors_clock"
f = engine.findings["tech.influx.sensors_clock"]
assert f.severity == Severity.ERROR and f.active
def test_influx_sensors_missing_without_log_cause(influx_rig):
engine, runner, clock, actions, board = influx_rig
runner.start("influx")
clock.t += 250
runner.influx_result("sensors_missing", "Status arrives, sensor data does not")
assert runner.end.id == "sensors_missing"
def test_influx_unavailable_and_stale_results(rig):
engine, runner, clock, actions, board, log = rig
runner._on_action = lambda a: False # no token / host cannot query
runner.start("influx")
assert runner.end.id == "unavailable"
runner.influx_result("ok", "late answer") # ignored: step already left
assert runner.end.id == "unavailable"
def test_influx_skip_uses_last_result(influx_rig):
engine, runner, clock, actions, board = influx_rig
runner.start("influx")
runner.influx_result("silent", "No recent data in InfluxDB")
runner.proceed()
assert runner.end.id == "silent"
def test_quick_check_green_ends_with_influx_check(influx_rig):
engine, runner, clock, actions, board = influx_rig
board(*BOOT)
runner.start("quick")
runner.answer("GREEN")
runner.answer("0") # heartbeat blinking
assert runner.guide.id == "influx" and actions == ["influx"]
def test_influx_rejected_upload_is_named(influx_rig):
engine, runner, clock, actions, board = influx_rig
board(*BOOT, "microphone,device=A4CB8FC9473C dbfs=-inf,db=-inf 1790805114000000000",
"io,device=A4CB8FC9473C dbfs=-inf,db=-inf 1790805215000000000") # USB dropped characters
assert sum(k.startswith("influx.invalid_value") for k in engine.findings) == 1
runner.start("influx")
runner.influx_result("silent", "No new data in InfluxDB since the test started")
clock.t += 300
runner.tick()
assert runner.end.id == "invalid_value"
+154
View File
@@ -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
+40
View File
@@ -86,6 +86,46 @@ def test_hard_reset_pushes_rts_via_dtr_write(monkeypatch):
assert all(c[1] is False for c in ser.calls if c[0] == "dtr")
class _PosixOpenSerial:
"""pyserial on macOS/Linux: the OS asserts both lines on open, then
pyserial writes the requested DTR, then RTS. Records every line state."""
def __init__(self, *args, **kwargs):
self.port = args[0] if args else "fake"
self.dtr = self.rts = True # pyserial defaults
self.states = []
def __setattr__(self, name, value):
object.__setattr__(self, name, value)
if name in ("dtr", "rts") and getattr(self, "is_open", False):
self.states.append((self.dtr, self.rts))
def open(self):
want_dtr, want_rts = self.dtr, self.rts
object.__setattr__(self, "dtr", True)
object.__setattr__(self, "rts", True)
self.is_open = True
self.states.append((True, True))
self.dtr = want_dtr
self.rts = want_rts
def test_open_on_posix_never_passes_through_reset(monkeypatch):
monkeypatch.setattr(mgr_mod.sys, "platform", "darwin")
monkeypatch.setattr(mgr_mod, "find_port", lambda sn, dev: LOOP)
opened = []
monkeypatch.setattr(
mgr_mod.serial, "serial_for_url",
lambda *a, **k: opened.append(_PosixOpenSerial(*a, **k)) or opened[-1],
)
m = SerialManager(lambda ln: None, lambda s, d: None)
m._target = mgr_mod._Target(LOOP.device, None)
ser = m._open()
# (DTR, RTS) = (False, True) resets the chip on connect.
assert (False, True) not in ser.states
assert ser.states[-1] == (False, False)
def test_waits_when_port_missing(monkeypatch):
monkeypatch.setattr(mgr_mod, "find_port", lambda sn, dev: None)
states, lines = [], []
+33
View File
@@ -15,5 +15,38 @@ def test_main_window_builds_with_all_tabs():
assert [tabs.tabText(i) for i in range(tabs.count())] == \
["Quick start", "Serial monitor", "Flash firmware", "Wi-Fi (USB)", "SD card"]
assert not w.wifi_tab.write_btn.isEnabled() # nothing connected, nothing typed
settings = [a.text() for m in w.menuBar().actions() for a in m.menu().actions()]
assert "InfluxDB access..." in settings
w.serial_tab.shutdown()
app.processEvents()
def test_serial_tab_checklist_and_test_strip():
from PySide6.QtWidgets import QApplication
from kota_tech.core.logline import parse_line
from kota_tech.service import influx
from kota_tech.ui.main_window import INFLUX_ROW, MainWindow
app = QApplication.instance() or QApplication([])
w = MainWindow()
st = w.serial_tab
assert st.test_btn.text().endswith("Test box")
assert [st.side_tabs.tabText(i) for i in range(st.side_tabs.count())] == \
["Live log", "Guided troubleshooting", "Box details"]
st.influx_config = lambda: None # no token (never touch real settings)
rows = st._cloud_rows()
assert rows[0][0] == "off" and "not set up" in rows[0][1]
st.influx_config = lambda: influx.InfluxConfig("t") # pretend access is set up
assert st._cloud_rows()[0] == ("pending", f"{INFLUX_ROW} - press Test box")
st._on_influx(influx.InfluxCheck(influx.SENSORS_MISSING, "Status arrives, sensor data does not",
details=["SEN66 (air quality): no data"]))
rows = st._cloud_rows()
assert rows[0][0] == "bad" and rows[1][0] == "note"
st._engine.feed(parse_line("ESP-ROM:esp32s3-20210327"))
st._diag_dirty = True
st._refresh_diag()
texts = [st.diag.milestones.item(i).text() for i in range(st.diag.milestones.count())]
assert any(INFLUX_ROW in t for t in texts)
assert "Test box" in st.test_status.text()
st.shutdown()
app.processEvents()