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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:34:01 +02:00
kkallas bfa7edce6f Technician tool v0.8
Imported from the Google Drive folder 004-technician_tool/v0.8.
2026-09-28 19:19:55 +02:00
kkallas dd915b5a74 Technician tool v0.7
Imported from the Google Drive folder 004-technician_tool/v0.7.
2026-09-28 19:11:20 +02:00
kkallas 22bd853ecf Technician tool v0.6
Imported from the Google Drive folder 004-technician_tool/v0.6.
2026-09-28 17:31:03 +02:00
50 changed files with 9813 additions and 4075 deletions

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
# KOTA Sensorbox Technician Tool
Diagnoza sensorboxa przez USB, wgrywanie firmware z folderu `firmware/` i przygotowanie karty SD.
Działa na Windows 10/11 i macOS 13 (Ventura) lub nowszym, na Macach z Intelem i Apple Silicon.
Repozytorium: https://gitea.kota-app.com/kkallas/kota-technician-tool (dostęp nadaje właściciel repozytorium).
## Dla technika: dwa pliki
| System | Aktualizacja | Uruchomienie |
|---|---|---|
| Windows | `update_windows.bat` | `run_windows.bat` |
| macOS | `update_macos.command` | `run_macos.command` |
- **Aktualizacja** sprawdza na Gitea najnowszą wydaną wersję i pobiera tylko to, co się zmieniło
(pliki programu, firmware i potrzebne biblioteki). Uruchom ją, gdy firmware team ogłosi nową wersję,
albo po prostu przed wyjazdem do klienta. Pokazuje, co się zmieniło.
- **Uruchomienie** startuje program. Nie łączy się z Gitea, działa też bez internetu (po pierwszej aktualizacji).
Nie trzymaj folderu narzędzia w Google Drive ani OneDrive.
## Pierwsza instalacja (raz na komputer)
1. Na Gitea otwórz **Releases** i przy najnowszej wersji pobierz **Source Code (ZIP)**.
2. Rozpakuj do zwykłego folderu, np. `Dokumenty\KOTA\technician-tool` (macOS: `~/Documents/KOTA/technician-tool`).
3. Uruchom plik aktualizacji dla swojego systemu. Za pierwszym razem:
- połączy folder z Gitea i zapyta o login do Gitea (hasło lub token); system je zapamięta,
- zainstaluje brakujące narzędzia za Twoją zgodą: Git (Windows: przez winget; macOS: Apple Command Line Tools
- po ich instalacji uruchom aktualizację jeszcze raz) oraz `uv` (menedżer Pythona, tylko dla bieżącego
użytkownika),
- pobierze biblioteki Pythona (1–2 minuty, potrzebny internet).
4. Uruchom program plikiem `run_...`.
**macOS:** jeśli Finder odpowie, że pliku nie można uruchomić (zdarza się po rozpakowaniu zip), otwórz Terminal,
wpisz `bash ` (ze spacją), przeciągnij `update_macos.command` do okna i naciśnij Enter. Po pierwszej aktualizacji
oba pliki `.command` dają się już otwierać dwuklikiem.
Zamiast zip można też zrobić `git clone https://gitea.kota-app.com/kkallas/kota-technician-tool.git` i dalej
używać tych samych dwóch plików.
Konkretna starsza wersja (np. do serwisu nietypowego boxa): w folderze narzędzia `git switch --detach v0.8`;
kolejna aktualizacja wraca do najnowszej.
Wszystkie wersje używają jednego środowiska Pythona poza folderem narzędzia:
`%LOCALAPPDATA%\KotaTechnicianTool\venv` (Windows), `~/Library/Caches/KotaTechnicianTool/venv` (macOS).
Stare środowiska `venv-0.x` z poprzednich wersji można usunąć.
Sesje, kopie zapasowe flash i ustawienia są w `~/KotaTechnicianTool` (Windows: `C:\Users\<użytkownik>\KotaTechnicianTool`).
## Port sensorboxa
- Windows: `COMx - Sensorbox (native USB) [MAC]`
- macOS: `/dev/cu.usbmodemXXXX - Sensorbox (native USB) [MAC]`
Kabel USB musi przenosić dane (nie tylko ładowanie). Przy restartach z komunikatem o brownout użyj innego portu
lub zasilacza.
## Firmware
Wgrać można tylko pliki `.bin` z folderu `firmware/`. Lista pokazuje datę kompilacji zapisaną w pliku,
najnowszy build jest na górze. Nowy build: wrzuć plik do `firmware/` i kliknij **Refresh list**.
## Dla zespołu
- Praca nad zmianą: gałąź (`git switch -c feature/<nazwa>`) → commity z przechodzącymi testami →
`git push -u origin feature/<nazwa>` → pull request na Gitea → merge do `main`.
- Wydanie wersji: numer w `pyproject.toml`, `kota_tech/__init__.py` i `uv.lock`, tag `vX.Y`
(`git tag -a vX.Y -m "Technician tool vX.Y"`, `git push origin vX.Y`), opis w **Releases** na Gitea.
Aktualizacja techników bierze **tylko tagi `v*`** – zmiany w `main` bez tagu do nich nie trafiają.
- Nie commitujemy `ENV.TXT` z prawdziwym tokenem ani haseł Wi-Fi.
- Testy:
```
uv run --frozen pytest -q
```
Zmiany w wersjach: `docs/CHANGES-v0.*.md`; historia v0.1–v0.8 jest w tagach gita.
+23
View File
@@ -0,0 +1,23 @@
# Technician tool v0.7 - changes
## Serial reading (firmware 1.0.16+, see firmware-diagnostics-what-changed.md)
- `KOTA: <record> key=value` lines are parsed into fields (`core/logline.py`, kind `KOTA`) and interpreted
directly by the engine (`diag/engine.py` `_kota_*`, knowledge in `diag/kota.py`) instead of regex rules:
boot, i2c, rtc, sd, matter, mic, health, status, led, restart.
- Exact status LED (colour + pattern + meaning) from `led` lines; `status` lines give the reason and a
status finding that is resolved when the status changes. Cyan LED supported.
- Restart cause from `last_restart=` / `restart reason=`; state before a restart from `led previous_boot`.
- Error IDs `[E-XX-NN]`: looked up in `diag/error_ids.csv` (copy of the firmware's docs/error_ids.csv);
shown with the technician action. Old message rules still match (ID prefix is stripped for matching).
- Diagnosis panel: new "Box self-report" section; log view filter "KOTA diagnostics + errors".
- Matter QR dialog uses the exact QR/manual code reported by the box.
- Older firmware (v1.0) is still diagnosed as before.
## Flashing
- Only `.bin` files in the tool's `firmware/` folder can be flashed (import / developer library removed,
no manifest needed). The list shows the compile date read from the firmware itself, newest first.
- "Refresh list" and "Open firmware folder" buttons; broken or duplicate files are listed as "Not offered".
- Flash verification compares the 8-digit build hash from the `KOTA: boot` line.
Note: the KOTA lines are tested against lines built from the firmware source; the firmware has not yet
been confirmed on hardware.
+21
View File
@@ -0,0 +1,21 @@
# Technician tool v0.8 - changes
## Windows + macOS
- Dependencies verified to install on macOS arm64 and x86_64 (uv.lock: PySide6 6.11 universal2 wheels,
requires macOS 13+); Windows unchanged.
- SD card check: macOS `diskutil` reports FAT32 partitions as `DOS_FAT_32` / `Windows_FAT_32`; these are now
accepted (previously a correct card was reported as "not FAT32" on a Mac).
- Unit tests for the macOS serial-port filter (`/dev/cu.*` only) and FAT32 names.
- README.md with launch steps for both systems (incl. the Google Drive executable-bit workaround on macOS);
docs/mac_agent_command.txt updated for a v0.8 test run on a Mac.
## Diagnosis
- Harmless Wi-Fi start-up errors are ignored (rule `benign.wifi_start`): `wifi:sta is connecting, return error`,
`esp_wifi_connect() failed: ESP_ERR_WIFI_CONN`, `Failed to get configured network when updating network
status` (unpaired box).
- Brown-out resets are detected (`E BOD: Brownout detector was triggered`, ROM reports RTC_SW_SYS_RST).
- Service report note = the Diagnosis panel as text (LED, firmware, box self-report, start-up progress with
times, findings with "Seen Nx, first at ... s, last at ... s" and log line), shortened to fit the service
form's 5000 characters.
Not yet run on a real Mac: see docs/mac_agent_command.txt.
+9
View File
@@ -0,0 +1,9 @@
# Technician tool v0.9 - changes
- Two files per system for technicians: `update_windows.bat` / `update_macos.command` (update) and
`run_windows.bat` / `run_macos.command` (start).
- Update: checks Gitea for the newest release tag `vX.Y`, downloads only what changed (git), shows the list of
changes, protects files edited by hand (asks first), connects a folder unpacked from a release zip on first
run, installs git / uv when missing (asks first) and brings the Python libraries up to date.
- One shared Python environment for all versions: `KotaTechnicianTool/venv` (was `venv-0.x` per version).
- A developer copy on a branch is left alone by the update script (use `git pull`).
+35 -30
View File
@@ -1,30 +1,35 @@
You are testing the KOTA sensorbox technician tool v0.5 on macOS. The current folder is You are testing the KOTA sensorbox technician tool v0.8 on macOS. The current folder is
004-technician_tool/v0.5 (a Python/PySide6 desktop app; it has only been tested on Windows so far). 004-technician_tool/v0.8 (a Python/PySide6 desktop app; tested on Windows 11, macOS paths are covered by
unit tests but not yet run on a real Mac).
Rules:
- Python only via uv or conda/mamba. Never use bare/system python or pip. Rules:
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin. - Python only via uv or conda/mamba. Never use bare/system python or pip.
- Do not push anything to Git or GitLab. This is a local demo. - Do not build or modify firmware. Flash only files from the tool's firmware/ folder.
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files. - Do not push anything to Git or GitLab. This is a local demo.
Before formatting, show me the exact disk (identifier, name, size, current files). - Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
- Do not change code without telling me. Report bugs with evidence (commands and output). Before formatting, show me the exact disk (identifier, name, size, current files).
- Do not change code without telling me. Report bugs with evidence (commands and output).
Tests, in order. Report pass/fail with evidence for each:
1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings. Tests, in order. Report pass/fail with evidence for each:
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q` 1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)" Also check whether a double-click in Finder works (Google Drive may drop the executable bit).
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the 2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8" uv run --frozen pytest -q`
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the (expect 172 passed).
reset and whether the tool reconnects. 3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows. entry with a MAC (no /dev/tty.* duplicate). Connect, press "Reset & capture": the log starts at
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself, "ESP-ROM:esp32s3" and the Diagnosis panel shows firmware v1.0.16 (build 4f10989e), the "Box self-report"
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output. section and the LED with its pattern. Check whether the port disappears after the reset and whether the
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed tool reconnects. Wait 2 minutes: a "KOTA: health" line appears every 60 s.
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT". 4. Show filter "KOTA diagnostics + errors" shows only KOTA lines and errors.
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical 5. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
to the template, and eject the card. 6. Flash (App only, the newest file in firmware/, chosen by its compile date): the tool must enter flashing mode
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine by itself, write, restart the box and report "Success ... build 4f10989e". Capture the esptool output.
if the page itself cannot prefill yet). 7. SD card tab: the card reader and card are listed (internal disks must NOT be listed). "Check card" must NOT
report a wrong file system for a FAT32 card. After my OK, "Format card and write ENV.TXT" must use
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT and eject the card.
suggested fixes (file:line). 8. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=...; the note in
the form must match the Diagnosis panel (self-report, start-up progress with times, findings with
"Seen Nx, first at ... s").
At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
suggested fixes (file:line).
+1
View File
@@ -8,4 +8,5 @@ dependencies:
- pyserial>=3.5 - pyserial>=3.5
- pyside6>=6.7 - pyside6>=6.7
- esptool>=4.8,<5 - esptool>=4.8,<5
- segno>=1.6
- pytest>=8 - pytest>=8
-11
View File
@@ -1,11 +0,0 @@
# Official firmware images the technician tool may flash.
# Boxes normally receive new firmware over OTA; this is the factory image a
# box updates itself from once it is online. Add a new image only when the
# firmware team releases one: copy the .bin here and add an entry with its
# SHA-256 (the tool refuses files that do not match).
[[image]]
file = "new_system.bin"
sha256 = "1d58a8f4bb60f66009bd6df32e6e860c545c0c668de955af60d049ee3b9a212d"
name = "v1.0 factory image (built Jun 29 2026)"
notes = "Official factory image. Once online, the box registers for OTA and updates itself."
+1 -1
View File
@@ -1,3 +1,3 @@
"""KOTA sensorbox technician tool.""" """KOTA sensorbox technician tool."""
__version__ = "0.5.0" __version__ = "0.9.0"
+202 -146
View File
@@ -1,146 +1,202 @@
"""Serial byte stream -> framed, cleaned, parsed log lines. """Serial byte stream -> framed, cleaned, parsed log lines.
ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally
wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset
reason) has no level prefix and is classified separately, since a boot reason) has no level prefix and is classified separately, since a boot
banner is the only reliable reboot marker. banner is the only reliable reboot marker.
"""
Firmware 1.0.16+ adds structured diagnostic lines logged under the "KOTA"
from __future__ import annotations tag ("I (812) KOTA: boot fw=1.0.16 build=4f10989e ... last_restart=none")
and prefixes every ESP_LOGE message with a stable error ID ("[E-SD-03] ").
import re Both are parsed here so the diagnostics engine reads fields directly
import time instead of matching message text.
from dataclasses import dataclass, field """
from enum import Enum
from __future__ import annotations
from .redact import redact
import re
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") import time
ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$") from dataclasses import dataclass, field
# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then from enum import Enum
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:") from .redact import redact
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
PANIC_RE = re.compile( ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|" ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
r"^Backtrace:|Rebooting\.\.\.$" # ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then
) # "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
class LineKind(str, Enum): # "[E-SD-03] Failed to initialize the card ..." (firmware error_ids.csv).
LOG = "log" # ESP_LOGx line ERROR_ID_RE = re.compile(r"^\[(E-[A-Z0-9]+-\d{2,})\] ?")
BOOT = "boot" # ROM boot banner (start of a new boot) KOTA_TAG = "KOTA"
RESET = "reset" # ROM reset-reason line PANIC_RE = re.compile(
PANIC = "panic" # crash / abort output r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|"
RAW = "raw" # anything else (task list rows, line protocol, ...) r"^Backtrace:|Rebooting\.\.\.$"
HOST = "host" # event generated by this tool (connect, reset, ...) )
def host_event(text: str) -> LogLine: class LineKind(str, Enum):
return LogLine(f"--- {text} ---", LineKind.HOST) LOG = "log" # ESP_LOGx line
BOOT = "boot" # ROM boot banner (start of a new boot)
RESET = "reset" # ROM reset-reason line
@dataclass(frozen=True) PANIC = "panic" # crash / abort output
class LogLine: KOTA = "kota" # structured "KOTA: <record> key=value ..." line
text: str # cleaned + redacted RAW = "raw" # anything else (task list rows, line protocol, ...)
kind: LineKind HOST = "host" # event generated by this tool (connect, reset, ...)
host_time: float = field(default_factory=time.time)
level: str | None = None # E/W/I/D/V for LOG lines
uptime_ms: int | None = None def host_event(text: str) -> LogLine:
tag: str | None = None return LogLine(f"--- {text} ---", LineKind.HOST)
message: str | None = None
reset_reason: str | None = None
@dataclass(frozen=True)
class LogLine:
def clean(raw: str) -> str: text: str # cleaned + redacted
return ANSI_RE.sub("", raw).replace("\r", "").rstrip() kind: LineKind
host_time: float = field(default_factory=time.time)
level: str | None = None # E/W/I/D/V for LOG lines
def parse_line(raw: str, host_time: float | None = None) -> LogLine: uptime_ms: int | None = None
text = redact(clean(raw)) tag: str | None = None
t = time.time() if host_time is None else host_time message: str | None = None
if m := ESP_LOG_RE.match(text): reset_reason: str | None = None
level, ms, tag, msg = m.groups() error_id: str | None = None # "E-SD-03" when the message carries one
return LogLine(text, LineKind.LOG, t, level, int(ms), tag.strip(), msg) record: str | None = None # KOTA lines: "boot", "i2c", "health", ...
if BOOT_BANNER_RE.match(text): fields: dict[str, str] = field(default_factory=dict) # KOTA key=value pairs
return LogLine(text, LineKind.BOOT, t) words: tuple[str, ...] = () # KOTA bare words ("verify", "OK", "->", ...)
if m := RESET_REASON_RE.match(text):
return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2)) @property
if PANIC_RE.search(text): def match_text(self) -> str:
return LogLine(text, LineKind.PANIC, t) """Text for message rules: the error-ID prefix removed, so rules
return LogLine(text, LineKind.RAW, t) written for older firmware keep matching."""
if self.error_id and self.message is not None and self.tag:
return self.text.replace(f"[{self.error_id}] ", "", 1)
class LineFramer: return self.text
"""Accumulates bytes and yields complete lines.
A partial line is released after `idle_flush_s` without new bytes so def parse_kota(body: str) -> tuple[str, dict[str, str], tuple[str, ...]]:
prompts / unterminated output are not held back forever. """'rtc verify running=1 delta=2s ok=1' -> ('rtc', {...}, ('verify',)).
Lines are also split where a new log line starts in the middle Values never contain spaces. The mic line has a parenthesised aside
("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores ("(GPIO3=1, expected 1)"): brackets and commas are stripped from keys
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old and values, and the aside's words are kept as bare words.
line while no host reads, releasing it glued in front of the next live """
output. After a (re)open, the fragment in front of the first such split tokens = body.split()
is stale and dropped. Timing cannot identify it, because the FIFO only if not tokens:
flushes when the board next prints, possibly seconds later. return "", {}, ()
""" fields: dict[str, str] = {}
words: list[str] = []
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096, for tok in tokens[1:]:
drop_first_partial: bool = False): key, eq, value = tok.partition("=")
self._buf = bytearray() if eq and key.strip("(,"):
self._last_rx = 0.0 key = key.lstrip("(")
self.idle_flush_s = idle_flush_s # "0x68=DS1307(missing)" keeps its bracket; "(GPIO3=1," loses it.
self.max_line = max_line value = value.rstrip(",")
self._first_line_pending = drop_first_partial if tok.startswith("(") or value.endswith(")") and "(" not in value:
value = value.rstrip(")")
def cancel_drop(self) -> None: fields[key] = value
self._first_line_pending = False else:
words.append(tok.strip("(),"))
def feed(self, data: bytes, now: float | None = None) -> list[str]: return tokens[0], fields, tuple(w for w in words if w)
now = time.monotonic() if now is None else now
if data:
self._buf.extend(data) def clean(raw: str) -> str:
self._last_rx = now return ANSI_RE.sub("", raw).replace("\r", "").rstrip()
out: list[str] = []
while (i := self._buf.find(b"\n")) >= 0:
out += self._emit(self._buf[:i]) def parse_line(raw: str, host_time: float | None = None) -> LogLine:
del self._buf[: i + 1] text = redact(clean(raw))
if len(self._buf) >= self.max_line: t = time.time() if host_time is None else host_time
out += self._emit(self._buf) if m := ESP_LOG_RE.match(text):
self._buf.clear() level, ms, tag, msg = m.groups()
return out tag = tag.strip()
if tag == KOTA_TAG:
def poll_idle(self, now: float | None = None) -> list[str]: record, fields, words = parse_kota(msg)
now = time.monotonic() if now is None else now return LogLine(text, LineKind.KOTA, t, level, int(ms), tag, msg,
if self._buf and now - self._last_rx >= self.idle_flush_s: record=record, fields=fields, words=words)
return self.flush() eid = None
return [] if em := ERROR_ID_RE.match(msg):
eid, msg = em.group(1), msg[em.end():]
def flush(self) -> list[str]: return LogLine(text, LineKind.LOG, t, level, int(ms), tag, msg, error_id=eid)
if not self._buf: if BOOT_BANNER_RE.match(text):
return [] return LogLine(text, LineKind.BOOT, t)
out = self._emit(self._buf) if m := RESET_REASON_RE.match(text):
self._buf.clear() return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2))
return out if PANIC_RE.search(text):
return LogLine(text, LineKind.PANIC, t)
def _emit(self, raw: bytes | bytearray) -> list[str]: return LogLine(text, LineKind.RAW, t)
parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
if self._first_line_pending:
self._first_line_pending = False class LineFramer:
if len(parts) > 1: """Accumulates bytes and yields complete lines.
parts = parts[1:] # stale FIFO fragment from before the open
return parts A partial line is released after `idle_flush_s` without new bytes so
prompts / unterminated output are not held back forever.
# Start of a log line (optionally ANSI-coloured) or ROM banner. Lines are also split where a new log line starts in the middle
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)") ("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old
line while no host reads, releasing it glued in front of the next live
def split_glued(text: str) -> list[str]: output. After a (re)open, the fragment in front of the first such split
# Left-to-right scan so an ANSI colour prefix stays with its line. is stale and dropped. Timing cannot identify it, because the FIFO only
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0] flushes when the board next prints, possibly seconds later.
if not cuts: """
return [text]
bounds = [0, *cuts, len(text)] def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b] drop_first_partial: bool = False):
self._buf = bytearray()
self._last_rx = 0.0
self.idle_flush_s = idle_flush_s
self.max_line = max_line
self._first_line_pending = drop_first_partial
def cancel_drop(self) -> None:
self._first_line_pending = False
def feed(self, data: bytes, now: float | None = None) -> list[str]:
now = time.monotonic() if now is None else now
if data:
self._buf.extend(data)
self._last_rx = now
out: list[str] = []
while (i := self._buf.find(b"\n")) >= 0:
out += self._emit(self._buf[:i])
del self._buf[: i + 1]
if len(self._buf) >= self.max_line:
out += self._emit(self._buf)
self._buf.clear()
return out
def poll_idle(self, now: float | None = None) -> list[str]:
now = time.monotonic() if now is None else now
if self._buf and now - self._last_rx >= self.idle_flush_s:
return self.flush()
return []
def flush(self) -> list[str]:
if not self._buf:
return []
out = self._emit(self._buf)
self._buf.clear()
return out
def _emit(self, raw: bytes | bytearray) -> list[str]:
parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
if self._first_line_pending:
self._first_line_pending = False
if len(parts) > 1:
parts = parts[1:] # stale FIFO fragment from before the open
return parts
# Start of a log line (optionally ANSI-coloured) or ROM banner.
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)")
def split_glued(text: str) -> list[str]:
# Left-to-right scan so an ANSI colour prefix stays with its line.
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0]
if not cuts:
return [text]
bounds = [0, *cuts, len(text)]
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b]
+999 -580
View File
File diff suppressed because it is too large. Load diff
+193
View File
@@ -0,0 +1,193 @@
id,module,severity,message,action
E-SD-01,SD,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-SD-02,SD,error,"Failed to mount filesystem. If you want the card to be formatted, set the CONFIG_EXAMPLE_FORMAT_IF_MOUNT_FAILED menuconfig option.",SD card is not FAT formatted or is corrupt; reformat it as FAT32 and restore ENV.TXT
E-SD-03,SD,error,Failed to initialize the card (%s). Make sure SD card lines have pull-up resistors in place.,Reseat the SD card; if it persists replace the card or check the SD connector
E-SD-04,SD,error,Failed to stat %s,Reseat the SD card; if it persists replace the card or check the SD connector
E-SD-05,SD,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-ENV-01,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-ENV-02,ENV,error,Failed to open .env file for reading,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
E-ENV-03,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-ENV-04,ENV,error,Failed to open existing .env file for reading; aborting write to avoid losing other variables,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
E-ENV-05,ENV,error,Failed to open .env file for writing,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
E-ENV-06,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-ENV-07,ENV,error,Failed to open existing .env file for reading; aborting delete to avoid losing other variables,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
E-ENV-08,ENV,error,Failed to open .env file for writing,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
E-ENV-09,ENV,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-INF-01,INF,error,Error %d at %s:%d,Internal error - send the log to the firmware team
E-INF-02,INF,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
E-INF-03,INF,error,"Failed to perform HTTP request: %d, %s",Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-04,INF,error,"Bad request, check line protocol format: %s",Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-05,INF,error,Failed to read INFLUX_URL from .env file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-06,INF,error,Failed to read INFLUX_TOKEN from .env file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-07,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-INF-08,INF,error,Failed to open buffer file for reading,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-09,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-INF-10,INF,error,Failed to open buffer file for clearing,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-11,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-INF-12,INF,error,Failed to create buffer file,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-13,INF,error,Failed to open buffer file for appending,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-14,INF,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-INF-15,INF,error,Failed to open buffer file for counting,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-16,INF,error,Failed to read buffer for flushing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-17,INF,error,Failed to write chunk to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-18,INF,error,Failed to write chunk with 5 retries to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-19,INF,error,Failed to clear buffer after flushing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-20,INF,error,Failed to flush buffer before writing: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-21,INF,error,Failed to write to Influx: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-INF-22,INF,error,Failed to append new entry to buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-OTA-01,OTA,error,Error %s at %s:%d,Internal error - send the log to the firmware team
E-OTA-02,OTA,error,Failed to create OTA task,Internal error - send the log to the firmware team
E-OTA-03,OTA,error,Failed to open NVS: %s,Internal error - send the log to the firmware team
E-OTA-04,OTA,error,Failed to read OTA token from NVS: %s,Internal error - send the log to the firmware team
E-OTA-05,OTA,error,Failed to read OTA token from NVS: %s,Internal error - send the log to the firmware team
E-OTA-06,OTA,error,Failed to get running partition,Check the network connection; if it persists send the log to the firmware team
E-OTA-07,OTA,error,Failed to get OTA state,Check the network connection; if it persists send the log to the firmware team
E-OTA-08,OTA,error,Failed to read update metadata from NVS: %s,Internal error - send the log to the firmware team
E-OTA-09,OTA,error,Failed to report failed selfcheck: %s,Check the network connection; if it persists send the log to the firmware team
E-OTA-10,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
E-OTA-11,OTA,error,Device registration failed with status code: %d,Check the network connection; if it persists send the log to the firmware team
E-OTA-12,OTA,error,Device registration failed: %s,Check the network connection; if it persists send the log to the firmware team
E-OTA-13,OTA,error,Failed to check for updates: %s,Check the network connection; if it persists send the log to the firmware team
E-OTA-14,OTA,error,Free stack size decreased from %u to %u,Internal error - send the log to the firmware team
E-OTA-15,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
E-OTA-16,OTA,error,OTA Update check failed with status code: %d,Check the network connection; if it persists send the log to the firmware team
E-OTA-17,OTA,error,"Update %s has already been tried %u times and failed, not trying again",Internal error - send the log to the firmware team
E-OTA-18,OTA,error,OTA update failed: %s,Check the network connection; if it persists send the log to the firmware team
E-OTA-19,OTA,error,Failed to read update metadata from NVS: %s,Internal error - send the log to the firmware team
E-OTA-20,OTA,error,Failed to read update ID from NVS: %s,Internal error - send the log to the firmware team
E-OTA-21,OTA,error,Failed to read update ID from NVS: %s,Internal error - send the log to the firmware team
E-OTA-22,OTA,error,Failed to read update tries from NVS: %s,Internal error - send the log to the firmware team
E-OTA-23,OTA,error,Failing selfcheck: %s,Firmware update failed its self check and will roll back; check the sensors listed in the log
E-OTA-24,OTA,error,Failed to send update result: %s,Check the network connection; if it persists send the log to the firmware team
E-OTA-25,OTA,error,Failed to mark app as invalid for rollback: %s,Internal error - send the log to the firmware team
E-OTA-26,OTA,error,Failed to send update result: %s,Check the network connection; if it persists send the log to the firmware team
E-OTA-27,OTA,error,Failed to mark app as valid to commit update: %s,Internal error - send the log to the firmware team
E-OTA-28,OTA,error,Failed to initialize HTTP client,Internal error - send the log to the firmware team
E-OTA-29,OTA,error,Failed to send update result with status code: %d,Check the network connection; if it persists send the log to the firmware team
E-WIFI-01,WIFI,error,esp_netif_init() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-02,WIFI,error,esp_event_loop_create_default() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-03,WIFI,error,esp_wifi_init() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-04,WIFI,error,Failed to register WIFI_EVENT handler: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-05,WIFI,error,Failed to register IP_EVENT handler: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-06,WIFI,error,esp_wifi_set_mode() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-07,WIFI,error,esp_timer_create() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-08,WIFI,error,esp_wifi_start() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-09,WIFI,error,esp_wifi_set_config() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-10,WIFI,error,esp_wifi_connect() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-11,WIFI,error,esp_wifi_set_mode(APSTA) failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-12,WIFI,error,esp_wifi_set_config(AP) failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-13,WIFI,error,esp_wifi_connect() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-14,WIFI,error,esp_wifi_scan_start() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-15,WIFI,error,esp_wifi_scan_get_ap_num() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-WIFI-16,WIFI,error,esp_wifi_scan_get_ap_records() failed: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-PROV-01,PROV,error,mdns_init() failed: %s,Retry WiFi provisioning (short button press); restart the device if it persists
E-PROV-02,PROV,error,httpd_start() failed: %s,Retry WiFi provisioning (short button press); restart the device if it persists
E-MAIN-01,MAIN,error,Error %d at %s:%d,Internal error - send the log to the firmware team
E-MAIN-02,MAIN,error,"Failed to open commissioning window, err:%",Internal error - send the log to the firmware team
E-MAIN-03,MAIN,error,"Attribute update callback: endpoint_id: %u, cluster_id: 0x%lx, attribute_id: 0x%lx",Internal error - send the log to the firmware team
E-MAIN-04,MAIN,error,Device MAC address: %02X:%02X:%02X:%02X:%02X:%02X,Internal error - send the log to the firmware team
E-MAIN-05,MAIN,error,Failed to initialize SD card: %s,Reseat the SD card; if it persists replace the card or check the SD connector
E-MAIN-06,MAIN,error,Failed to initialize EnvironmentService: %s,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
E-MAIN-07,MAIN,error,Failed to initialize WifiCredentialsService: %s,Check ENV.TXT on the SD card exists and has NAME=VALUE lines
E-MAIN-08,MAIN,error,WiFi provisioning server failed to start: %s; clearing HTTP_SERVER and restarting,Retry WiFi provisioning (short button press); restart the device if it persists
E-MAIN-09,MAIN,error,Failed to create Matter node,Internal error - send the log to the firmware team
E-MAIN-10,MAIN,error,Failed to start ESP-Matter,Internal error - send the log to the firmware team
E-MAIN-11,MAIN,error,Failed to start WifiService: %s,Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
E-MAIN-12,MAIN,error,"Failed to initialized the encrypted OTA, err: %d",Internal error - send the log to the firmware team
E-MAIN-13,MAIN,error,Failed to initialize RTC: %s,Check the RTC (DS1307) and the sensor cable
E-MAIN-14,MAIN,error,Failed to initialize Influx buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-MAIN-15,MAIN,error,Failed to initialize BME688: %s,Check the BME688 sensor and the sensor cable
E-MAIN-16,MAIN,error,Failed to initialize SEN66: %s,"Check the SEN66 sensor, its power supply and the sensor cable"
E-MAIN-17,MAIN,error,Failed to initialize AS7341: %s,Check the AS7341 light sensor and the sensor cable
E-MAIN-18,MAIN,error,Failed to initialize Microphone: %s,Check the microphone board and its cable
E-MAIN-19,MAIN,error,Failed to initialize Flicker sensor: %s,Check the flicker sensor and its cable
E-MAIN-20,MAIN,error,Failed to initialize DeviceStatus: %s,Internal error - send the log to the firmware team
E-MAIN-21,MAIN,error,BME688 self check failed: %s,Check the BME688 sensor and the sensor cable
E-MAIN-22,MAIN,error,SEN66 self check failed: %s,"Check the SEN66 sensor, its power supply and the sensor cable"
E-MAIN-23,MAIN,error,AS7341 self check failed: %s,Check the AS7341 light sensor and the sensor cable
E-MAIN-24,MAIN,error,Microphone self check failed: %s,Check the microphone board and its cable
E-MAIN-25,MAIN,error,Flicker sensor self check failed: %s,Check the flicker sensor and its cable
E-MAIN-26,MAIN,error,Failed to report passed selfcheck: %s,Internal error - send the log to the firmware team
E-MAIN-27,MAIN,error,Failed to flush Influx buffer: %s,Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
E-STAT-01,STAT,error,Failed to initialize LED strip: %s,Internal error - send the log to the firmware team
E-SPI-01,SPI,error,Failed to take SPI mutex,Internal error - send the log to the firmware team
E-RTC-01,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-RTC-02,RTC,error,Failed to add I2C device: %s,Check the RTC (DS1307) and the sensor cable
E-RTC-03,RTC,error,Failed to initialize DS1307 control register: %s,Check the RTC (DS1307) and the sensor cable
E-RTC-04,RTC,error,Attempted to set time on uninitialized DS1307,Internal error - send the log to the firmware team
E-RTC-05,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-RTC-06,RTC,error,Attempted to get time on uninitialized DS1307,Internal error - send the log to the firmware team
E-RTC-07,RTC,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-RTC-08,RTC,error,Attempted to start updating time on uninitialized DS1307,Internal error - send the log to the firmware team
E-BME-01,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-BME-02,BME,error,Failed to add I2C device: %d,Check the BME688 sensor and the sensor cable
E-BME-03,BME,error,Failed to initialize BME688: %d,Check the BME688 sensor and the sensor cable
E-BME-04,BME,error,Failed to set BME688 configuration: %d,Check the BME688 sensor and the sensor cable
E-BME-05,BME,error,Failed to set BME688 heater configuration: %d,Check the BME688 sensor and the sensor cable
E-BME-06,BME,error,Failed to set BME688 operation mode: %d,Check the BME688 sensor and the sensor cable
E-BME-07,BME,error,Failed to get BME688 data: %d,Check the BME688 sensor and the sensor cable
E-BME-08,BME,error,No new data available from BME688,Check the BME688 sensor and the sensor cable
E-BME-09,BME,error,Failed to get data from BME688: %s,Check the BME688 sensor and the sensor cable
E-BME-10,BME,error,Self check failed: %s,Check the BME688 sensor and the sensor cable
E-BME-11,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-BME-12,BME,error,I2C read failed: %s,Check the BME688 sensor and the sensor cable
E-BME-13,BME,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-BME-14,BME,error,Failed to allocate memory for I2C write buffer,Internal error - send the log to the firmware team
E-BME-15,BME,error,I2C write failed: %s,Check the BME688 sensor and the sensor cable
E-SEN-01,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-SEN-02,SEN,error,Failed to add I2C device: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-03,SEN,error,Attempted to wake up uninitialized SEN66,Internal error - send the log to the firmware team
E-SEN-04,SEN,error,Failed to take sensor mutex,Internal error - send the log to the firmware team
E-SEN-05,SEN,error,Failed to send start measurement command: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-06,SEN,error,Attempted to sleep uninitialized SEN66,Internal error - send the log to the firmware team
E-SEN-07,SEN,error,Failed to take sensor mutex,Internal error - send the log to the firmware team
E-SEN-08,SEN,error,Failed to send stop measurement command: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-09,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-SEN-10,SEN,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-SEN-11,SEN,error,Failed to read ready status: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-12,SEN,error,"Sensor not ready after %d attempts, trying to wake it up","Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-13,SEN,error,Failed to read measurement: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-14,SEN,error,Failed while awaiting sensor ready: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-15,SEN,error,Failed to read measurement for line protocol: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-16,SEN,error,Self-check failed: Sensor not ready: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-SEN-17,SEN,error,Self-check failed: Failed to read measurement: %d,"Check the SEN66 sensor, its power supply and the sensor cable"
E-AS-01,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-AS-02,AS,error,Failed to initialize AS7341: %s,Check the AS7341 light sensor and the sensor cable
E-AS-03,AS,error,Attempted to wake up uninitialized AS7341,Internal error - send the log to the firmware team
E-AS-04,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-AS-05,AS,error,Failed to wake up AS7341: %s,Check the AS7341 light sensor and the sensor cable
E-AS-06,AS,error,Attempted to put uninitialized AS7341 to sleep,Internal error - send the log to the firmware team
E-AS-07,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-AS-08,AS,error,Failed to put AS7341 to sleep: %s,Check the AS7341 light sensor and the sensor cable
E-AS-09,AS,error,Attempted to get line protocol on uninitialized AS7341,Internal error - send the log to the firmware team
E-AS-10,AS,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-AS-11,AS,error,Failed to read spectral data: %s,Check the AS7341 light sensor and the sensor cable
E-AS-12,AS,error,Failed to read basic counts data: %s,Check the AS7341 light sensor and the sensor cable
E-AS-13,AS,error,Failed to read flicker detection state: %s,Check the AS7341 light sensor and the sensor cable
E-AS-14,AS,error,Self-check failed: Failed to read spectral data: %s,Check the AS7341 light sensor and the sensor cable
E-MIC-01,MIC,error,Failed to create I2S channel: %s,Check the microphone board and its cable
E-MIC-02,MIC,error,Failed to initialize I2S channel in standard mode: %s,Check the microphone board and its cable
E-MIC-03,MIC,error,Failed to configure shutdown pin: %s,Check the microphone board and its cable
E-MIC-04,MIC,error,Failed to read samples from I2S channel: %s,Check the microphone board and its cable
E-MIC-05,MIC,error,Failed to read samples for line protocol: %s,Check the microphone board and its cable
E-FLK-01,FLK,error,Error %s at %s:%d,Internal error - send the log to the firmware team
E-FLK-02,FLK,error,Attempting to initialize sensor that is already initialized,Check the flicker sensor and its cable
E-FLK-03,FLK,error,Failed to allocate memory for FFT buffers,Internal error - send the log to the firmware team
E-FLK-04,FLK,error,Failed to initialize FFT: %s,Check the flicker sensor and its cable
E-FLK-05,FLK,error,Attempting to read from uninitialized sensor,Internal error - send the log to the firmware team
E-FLK-06,FLK,error,Failed to take I2C mutex,Internal error - send the log to the firmware team
E-FLK-07,FLK,error,Failed to get dominant frequency: %s,Check the flicker sensor and its cable
E-FLK-08,FLK,error,Attempting to read from uninitialized sensor,Internal error - send the log to the firmware team
E-FLK-09,FLK,error,Failed to read ADC value: %s,Check the flicker sensor and its cable
E-FLK-10,FLK,error,Failed to create gather task: %s,Internal error - send the log to the firmware team
E-FLK-11,FLK,error,Attempting to self check uninitialized sensor,Internal error - send the log to the firmware team
E-FLK-12,FLK,error,Self check failed to get dominant frequency: %s,Check the flicker sensor and its cable
E-DEV-01,DEV,error,Failed to initialize ADC unit: %s,Internal error - send the log to the firmware team
E-DEV-02,DEV,error,Failed to configure ADC channel: %s,Internal error - send the log to the firmware team
E-DEV-03,DEV,error,Failed to install temperature sensor: %s,Internal error - send the log to the firmware team
E-DEV-04,DEV,error,Failed to read ADC value: %s,Internal error - send the log to the firmware team
E-DEV-05,DEV,error,Failed to allocate memory for task list buffer,Internal error - send the log to the firmware team
E-GATH-01,GATH,error,Failed to take line protocol mutex,Internal error - send the log to the firmware team
E-GATH-02,GATH,error,Failed to get line protocol from sensor,Device ran out of memory; restart it and send the log to the firmware team
E-GATH-03,GATH,error,Failed to create task for sensor,Internal error - send the log to the firmware team
E-GATH-04,GATH,error,Failed to create main gather task,Internal error - send the log to the firmware team
1 id module severity message action
2 E-SD-01 SD error Failed to take SPI mutex Internal error - send the log to the firmware team
3 E-SD-02 SD error Failed to mount filesystem. If you want the card to be formatted, set the CONFIG_EXAMPLE_FORMAT_IF_MOUNT_FAILED menuconfig option. SD card is not FAT formatted or is corrupt; reformat it as FAT32 and restore ENV.TXT
4 E-SD-03 SD error Failed to initialize the card (%s). Make sure SD card lines have pull-up resistors in place. Reseat the SD card; if it persists replace the card or check the SD connector
5 E-SD-04 SD error Failed to stat %s Reseat the SD card; if it persists replace the card or check the SD connector
6 E-SD-05 SD error Failed to take SPI mutex Internal error - send the log to the firmware team
7 E-ENV-01 ENV error Failed to take SPI mutex Internal error - send the log to the firmware team
8 E-ENV-02 ENV error Failed to open .env file for reading Check ENV.TXT on the SD card exists and has NAME=VALUE lines
9 E-ENV-03 ENV error Failed to take SPI mutex Internal error - send the log to the firmware team
10 E-ENV-04 ENV error Failed to open existing .env file for reading; aborting write to avoid losing other variables Check ENV.TXT on the SD card exists and has NAME=VALUE lines
11 E-ENV-05 ENV error Failed to open .env file for writing Check ENV.TXT on the SD card exists and has NAME=VALUE lines
12 E-ENV-06 ENV error Failed to take SPI mutex Internal error - send the log to the firmware team
13 E-ENV-07 ENV error Failed to open existing .env file for reading; aborting delete to avoid losing other variables Check ENV.TXT on the SD card exists and has NAME=VALUE lines
14 E-ENV-08 ENV error Failed to open .env file for writing Check ENV.TXT on the SD card exists and has NAME=VALUE lines
15 E-ENV-09 ENV error Failed to take SPI mutex Internal error - send the log to the firmware team
16 E-INF-01 INF error Error %d at %s:%d Internal error - send the log to the firmware team
17 E-INF-02 INF error Failed to initialize HTTP client Internal error - send the log to the firmware team
18 E-INF-03 INF error Failed to perform HTTP request: %d, %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
19 E-INF-04 INF error Bad request, check line protocol format: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
20 E-INF-05 INF error Failed to read INFLUX_URL from .env file Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
21 E-INF-06 INF error Failed to read INFLUX_TOKEN from .env file Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
22 E-INF-07 INF error Failed to take SPI mutex Internal error - send the log to the firmware team
23 E-INF-08 INF error Failed to open buffer file for reading Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
24 E-INF-09 INF error Failed to take SPI mutex Internal error - send the log to the firmware team
25 E-INF-10 INF error Failed to open buffer file for clearing Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
26 E-INF-11 INF error Failed to take SPI mutex Internal error - send the log to the firmware team
27 E-INF-12 INF error Failed to create buffer file Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
28 E-INF-13 INF error Failed to open buffer file for appending Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
29 E-INF-14 INF error Failed to take SPI mutex Internal error - send the log to the firmware team
30 E-INF-15 INF error Failed to open buffer file for counting Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
31 E-INF-16 INF error Failed to read buffer for flushing: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
32 E-INF-17 INF error Failed to write chunk to Influx: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
33 E-INF-18 INF error Failed to write chunk with 5 retries to Influx: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
34 E-INF-19 INF error Failed to clear buffer after flushing: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
35 E-INF-20 INF error Failed to flush buffer before writing: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
36 E-INF-21 INF error Failed to write to Influx: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
37 E-INF-22 INF error Failed to append new entry to buffer: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
38 E-OTA-01 OTA error Error %s at %s:%d Internal error - send the log to the firmware team
39 E-OTA-02 OTA error Failed to create OTA task Internal error - send the log to the firmware team
40 E-OTA-03 OTA error Failed to open NVS: %s Internal error - send the log to the firmware team
41 E-OTA-04 OTA error Failed to read OTA token from NVS: %s Internal error - send the log to the firmware team
42 E-OTA-05 OTA error Failed to read OTA token from NVS: %s Internal error - send the log to the firmware team
43 E-OTA-06 OTA error Failed to get running partition Check the network connection; if it persists send the log to the firmware team
44 E-OTA-07 OTA error Failed to get OTA state Check the network connection; if it persists send the log to the firmware team
45 E-OTA-08 OTA error Failed to read update metadata from NVS: %s Internal error - send the log to the firmware team
46 E-OTA-09 OTA error Failed to report failed selfcheck: %s Check the network connection; if it persists send the log to the firmware team
47 E-OTA-10 OTA error Failed to initialize HTTP client Internal error - send the log to the firmware team
48 E-OTA-11 OTA error Device registration failed with status code: %d Check the network connection; if it persists send the log to the firmware team
49 E-OTA-12 OTA error Device registration failed: %s Check the network connection; if it persists send the log to the firmware team
50 E-OTA-13 OTA error Failed to check for updates: %s Check the network connection; if it persists send the log to the firmware team
51 E-OTA-14 OTA error Free stack size decreased from %u to %u Internal error - send the log to the firmware team
52 E-OTA-15 OTA error Failed to initialize HTTP client Internal error - send the log to the firmware team
53 E-OTA-16 OTA error OTA Update check failed with status code: %d Check the network connection; if it persists send the log to the firmware team
54 E-OTA-17 OTA error Update %s has already been tried %u times and failed, not trying again Internal error - send the log to the firmware team
55 E-OTA-18 OTA error OTA update failed: %s Check the network connection; if it persists send the log to the firmware team
56 E-OTA-19 OTA error Failed to read update metadata from NVS: %s Internal error - send the log to the firmware team
57 E-OTA-20 OTA error Failed to read update ID from NVS: %s Internal error - send the log to the firmware team
58 E-OTA-21 OTA error Failed to read update ID from NVS: %s Internal error - send the log to the firmware team
59 E-OTA-22 OTA error Failed to read update tries from NVS: %s Internal error - send the log to the firmware team
60 E-OTA-23 OTA error Failing selfcheck: %s Firmware update failed its self check and will roll back; check the sensors listed in the log
61 E-OTA-24 OTA error Failed to send update result: %s Check the network connection; if it persists send the log to the firmware team
62 E-OTA-25 OTA error Failed to mark app as invalid for rollback: %s Internal error - send the log to the firmware team
63 E-OTA-26 OTA error Failed to send update result: %s Check the network connection; if it persists send the log to the firmware team
64 E-OTA-27 OTA error Failed to mark app as valid to commit update: %s Internal error - send the log to the firmware team
65 E-OTA-28 OTA error Failed to initialize HTTP client Internal error - send the log to the firmware team
66 E-OTA-29 OTA error Failed to send update result with status code: %d Check the network connection; if it persists send the log to the firmware team
67 E-WIFI-01 WIFI error esp_netif_init() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
68 E-WIFI-02 WIFI error esp_event_loop_create_default() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
69 E-WIFI-03 WIFI error esp_wifi_init() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
70 E-WIFI-04 WIFI error Failed to register WIFI_EVENT handler: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
71 E-WIFI-05 WIFI error Failed to register IP_EVENT handler: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
72 E-WIFI-06 WIFI error esp_wifi_set_mode() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
73 E-WIFI-07 WIFI error esp_timer_create() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
74 E-WIFI-08 WIFI error esp_wifi_start() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
75 E-WIFI-09 WIFI error esp_wifi_set_config() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
76 E-WIFI-10 WIFI error esp_wifi_connect() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
77 E-WIFI-11 WIFI error esp_wifi_set_mode(APSTA) failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
78 E-WIFI-12 WIFI error esp_wifi_set_config(AP) failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
79 E-WIFI-13 WIFI error esp_wifi_connect() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
80 E-WIFI-14 WIFI error esp_wifi_scan_start() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
81 E-WIFI-15 WIFI error esp_wifi_scan_get_ap_num() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
82 E-WIFI-16 WIFI error esp_wifi_scan_get_ap_records() failed: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
83 E-PROV-01 PROV error mdns_init() failed: %s Retry WiFi provisioning (short button press); restart the device if it persists
84 E-PROV-02 PROV error httpd_start() failed: %s Retry WiFi provisioning (short button press); restart the device if it persists
85 E-MAIN-01 MAIN error Error %d at %s:%d Internal error - send the log to the firmware team
86 E-MAIN-02 MAIN error Failed to open commissioning window, err:% Internal error - send the log to the firmware team
87 E-MAIN-03 MAIN error Attribute update callback: endpoint_id: %u, cluster_id: 0x%lx, attribute_id: 0x%lx Internal error - send the log to the firmware team
88 E-MAIN-04 MAIN error Device MAC address: %02X:%02X:%02X:%02X:%02X:%02X Internal error - send the log to the firmware team
89 E-MAIN-05 MAIN error Failed to initialize SD card: %s Reseat the SD card; if it persists replace the card or check the SD connector
90 E-MAIN-06 MAIN error Failed to initialize EnvironmentService: %s Check ENV.TXT on the SD card exists and has NAME=VALUE lines
91 E-MAIN-07 MAIN error Failed to initialize WifiCredentialsService: %s Check ENV.TXT on the SD card exists and has NAME=VALUE lines
92 E-MAIN-08 MAIN error WiFi provisioning server failed to start: %s; clearing HTTP_SERVER and restarting Retry WiFi provisioning (short button press); restart the device if it persists
93 E-MAIN-09 MAIN error Failed to create Matter node Internal error - send the log to the firmware team
94 E-MAIN-10 MAIN error Failed to start ESP-Matter Internal error - send the log to the firmware team
95 E-MAIN-11 MAIN error Failed to start WifiService: %s Check WiFi signal and credentials; re-run WiFi provisioning (short button press)
96 E-MAIN-12 MAIN error Failed to initialized the encrypted OTA, err: %d Internal error - send the log to the firmware team
97 E-MAIN-13 MAIN error Failed to initialize RTC: %s Check the RTC (DS1307) and the sensor cable
98 E-MAIN-14 MAIN error Failed to initialize Influx buffer: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
99 E-MAIN-15 MAIN error Failed to initialize BME688: %s Check the BME688 sensor and the sensor cable
100 E-MAIN-16 MAIN error Failed to initialize SEN66: %s Check the SEN66 sensor, its power supply and the sensor cable
101 E-MAIN-17 MAIN error Failed to initialize AS7341: %s Check the AS7341 light sensor and the sensor cable
102 E-MAIN-18 MAIN error Failed to initialize Microphone: %s Check the microphone board and its cable
103 E-MAIN-19 MAIN error Failed to initialize Flicker sensor: %s Check the flicker sensor and its cable
104 E-MAIN-20 MAIN error Failed to initialize DeviceStatus: %s Internal error - send the log to the firmware team
105 E-MAIN-21 MAIN error BME688 self check failed: %s Check the BME688 sensor and the sensor cable
106 E-MAIN-22 MAIN error SEN66 self check failed: %s Check the SEN66 sensor, its power supply and the sensor cable
107 E-MAIN-23 MAIN error AS7341 self check failed: %s Check the AS7341 light sensor and the sensor cable
108 E-MAIN-24 MAIN error Microphone self check failed: %s Check the microphone board and its cable
109 E-MAIN-25 MAIN error Flicker sensor self check failed: %s Check the flicker sensor and its cable
110 E-MAIN-26 MAIN error Failed to report passed selfcheck: %s Internal error - send the log to the firmware team
111 E-MAIN-27 MAIN error Failed to flush Influx buffer: %s Check the network connection and INFLUX_URL / INFLUX_TOKEN in ENV.TXT
112 E-STAT-01 STAT error Failed to initialize LED strip: %s Internal error - send the log to the firmware team
113 E-SPI-01 SPI error Failed to take SPI mutex Internal error - send the log to the firmware team
114 E-RTC-01 RTC error Failed to take I2C mutex Internal error - send the log to the firmware team
115 E-RTC-02 RTC error Failed to add I2C device: %s Check the RTC (DS1307) and the sensor cable
116 E-RTC-03 RTC error Failed to initialize DS1307 control register: %s Check the RTC (DS1307) and the sensor cable
117 E-RTC-04 RTC error Attempted to set time on uninitialized DS1307 Internal error - send the log to the firmware team
118 E-RTC-05 RTC error Failed to take I2C mutex Internal error - send the log to the firmware team
119 E-RTC-06 RTC error Attempted to get time on uninitialized DS1307 Internal error - send the log to the firmware team
120 E-RTC-07 RTC error Failed to take I2C mutex Internal error - send the log to the firmware team
121 E-RTC-08 RTC error Attempted to start updating time on uninitialized DS1307 Internal error - send the log to the firmware team
122 E-BME-01 BME error Failed to take I2C mutex Internal error - send the log to the firmware team
123 E-BME-02 BME error Failed to add I2C device: %d Check the BME688 sensor and the sensor cable
124 E-BME-03 BME error Failed to initialize BME688: %d Check the BME688 sensor and the sensor cable
125 E-BME-04 BME error Failed to set BME688 configuration: %d Check the BME688 sensor and the sensor cable
126 E-BME-05 BME error Failed to set BME688 heater configuration: %d Check the BME688 sensor and the sensor cable
127 E-BME-06 BME error Failed to set BME688 operation mode: %d Check the BME688 sensor and the sensor cable
128 E-BME-07 BME error Failed to get BME688 data: %d Check the BME688 sensor and the sensor cable
129 E-BME-08 BME error No new data available from BME688 Check the BME688 sensor and the sensor cable
130 E-BME-09 BME error Failed to get data from BME688: %s Check the BME688 sensor and the sensor cable
131 E-BME-10 BME error Self check failed: %s Check the BME688 sensor and the sensor cable
132 E-BME-11 BME error Failed to take I2C mutex Internal error - send the log to the firmware team
133 E-BME-12 BME error I2C read failed: %s Check the BME688 sensor and the sensor cable
134 E-BME-13 BME error Failed to take I2C mutex Internal error - send the log to the firmware team
135 E-BME-14 BME error Failed to allocate memory for I2C write buffer Internal error - send the log to the firmware team
136 E-BME-15 BME error I2C write failed: %s Check the BME688 sensor and the sensor cable
137 E-SEN-01 SEN error Failed to take I2C mutex Internal error - send the log to the firmware team
138 E-SEN-02 SEN error Failed to add I2C device: %d Check the SEN66 sensor, its power supply and the sensor cable
139 E-SEN-03 SEN error Attempted to wake up uninitialized SEN66 Internal error - send the log to the firmware team
140 E-SEN-04 SEN error Failed to take sensor mutex Internal error - send the log to the firmware team
141 E-SEN-05 SEN error Failed to send start measurement command: %d Check the SEN66 sensor, its power supply and the sensor cable
142 E-SEN-06 SEN error Attempted to sleep uninitialized SEN66 Internal error - send the log to the firmware team
143 E-SEN-07 SEN error Failed to take sensor mutex Internal error - send the log to the firmware team
144 E-SEN-08 SEN error Failed to send stop measurement command: %d Check the SEN66 sensor, its power supply and the sensor cable
145 E-SEN-09 SEN error Failed to take I2C mutex Internal error - send the log to the firmware team
146 E-SEN-10 SEN error Failed to take I2C mutex Internal error - send the log to the firmware team
147 E-SEN-11 SEN error Failed to read ready status: %d Check the SEN66 sensor, its power supply and the sensor cable
148 E-SEN-12 SEN error Sensor not ready after %d attempts, trying to wake it up Check the SEN66 sensor, its power supply and the sensor cable
149 E-SEN-13 SEN error Failed to read measurement: %d Check the SEN66 sensor, its power supply and the sensor cable
150 E-SEN-14 SEN error Failed while awaiting sensor ready: %d Check the SEN66 sensor, its power supply and the sensor cable
151 E-SEN-15 SEN error Failed to read measurement for line protocol: %d Check the SEN66 sensor, its power supply and the sensor cable
152 E-SEN-16 SEN error Self-check failed: Sensor not ready: %d Check the SEN66 sensor, its power supply and the sensor cable
153 E-SEN-17 SEN error Self-check failed: Failed to read measurement: %d Check the SEN66 sensor, its power supply and the sensor cable
154 E-AS-01 AS error Failed to take I2C mutex Internal error - send the log to the firmware team
155 E-AS-02 AS error Failed to initialize AS7341: %s Check the AS7341 light sensor and the sensor cable
156 E-AS-03 AS error Attempted to wake up uninitialized AS7341 Internal error - send the log to the firmware team
157 E-AS-04 AS error Failed to take I2C mutex Internal error - send the log to the firmware team
158 E-AS-05 AS error Failed to wake up AS7341: %s Check the AS7341 light sensor and the sensor cable
159 E-AS-06 AS error Attempted to put uninitialized AS7341 to sleep Internal error - send the log to the firmware team
160 E-AS-07 AS error Failed to take I2C mutex Internal error - send the log to the firmware team
161 E-AS-08 AS error Failed to put AS7341 to sleep: %s Check the AS7341 light sensor and the sensor cable
162 E-AS-09 AS error Attempted to get line protocol on uninitialized AS7341 Internal error - send the log to the firmware team
163 E-AS-10 AS error Failed to take I2C mutex Internal error - send the log to the firmware team
164 E-AS-11 AS error Failed to read spectral data: %s Check the AS7341 light sensor and the sensor cable
165 E-AS-12 AS error Failed to read basic counts data: %s Check the AS7341 light sensor and the sensor cable
166 E-AS-13 AS error Failed to read flicker detection state: %s Check the AS7341 light sensor and the sensor cable
167 E-AS-14 AS error Self-check failed: Failed to read spectral data: %s Check the AS7341 light sensor and the sensor cable
168 E-MIC-01 MIC error Failed to create I2S channel: %s Check the microphone board and its cable
169 E-MIC-02 MIC error Failed to initialize I2S channel in standard mode: %s Check the microphone board and its cable
170 E-MIC-03 MIC error Failed to configure shutdown pin: %s Check the microphone board and its cable
171 E-MIC-04 MIC error Failed to read samples from I2S channel: %s Check the microphone board and its cable
172 E-MIC-05 MIC error Failed to read samples for line protocol: %s Check the microphone board and its cable
173 E-FLK-01 FLK error Error %s at %s:%d Internal error - send the log to the firmware team
174 E-FLK-02 FLK error Attempting to initialize sensor that is already initialized Check the flicker sensor and its cable
175 E-FLK-03 FLK error Failed to allocate memory for FFT buffers Internal error - send the log to the firmware team
176 E-FLK-04 FLK error Failed to initialize FFT: %s Check the flicker sensor and its cable
177 E-FLK-05 FLK error Attempting to read from uninitialized sensor Internal error - send the log to the firmware team
178 E-FLK-06 FLK error Failed to take I2C mutex Internal error - send the log to the firmware team
179 E-FLK-07 FLK error Failed to get dominant frequency: %s Check the flicker sensor and its cable
180 E-FLK-08 FLK error Attempting to read from uninitialized sensor Internal error - send the log to the firmware team
181 E-FLK-09 FLK error Failed to read ADC value: %s Check the flicker sensor and its cable
182 E-FLK-10 FLK error Failed to create gather task: %s Internal error - send the log to the firmware team
183 E-FLK-11 FLK error Attempting to self check uninitialized sensor Internal error - send the log to the firmware team
184 E-FLK-12 FLK error Self check failed to get dominant frequency: %s Check the flicker sensor and its cable
185 E-DEV-01 DEV error Failed to initialize ADC unit: %s Internal error - send the log to the firmware team
186 E-DEV-02 DEV error Failed to configure ADC channel: %s Internal error - send the log to the firmware team
187 E-DEV-03 DEV error Failed to install temperature sensor: %s Internal error - send the log to the firmware team
188 E-DEV-04 DEV error Failed to read ADC value: %s Internal error - send the log to the firmware team
189 E-DEV-05 DEV error Failed to allocate memory for task list buffer Internal error - send the log to the firmware team
190 E-GATH-01 GATH error Failed to take line protocol mutex Internal error - send the log to the firmware team
191 E-GATH-02 GATH error Failed to get line protocol from sensor Device ran out of memory; restart it and send the log to the firmware team
192 E-GATH-03 GATH error Failed to create task for sensor Internal error - send the log to the firmware team
193 E-GATH-04 GATH error Failed to create main gather task Internal error - send the log to the firmware team
+147 -91
View File
@@ -1,91 +1,147 @@
"""Firmware identification from the boot log. """Firmware identification from the boot log.
The app version string is "1.0" in every build seen so far, so builds are Up to firmware 1.0 the app version string was "1.0" in every build, so
told apart by the ELF SHA256 prefix printed by app_init. Boxes receive new builds are told apart by the ELF SHA256 prefix printed by app_init. Boxes
firmware over OTA; an unknown SHA usually means a box has already updated. receive new firmware over OTA; an unknown SHA usually means a box has already
""" updated.
from __future__ import annotations Firmware 1.0.16+ prints "KOTA: boot fw=1.0.16 build=4f10989e ..." (version
unique per build, first 8 hex digits of the ELF SHA256). Builds found in the
import re tool's firmware/ folder are registered with `register_local_build` so the
from dataclasses import dataclass, field box's build can be shown with its compile date.
"""
@dataclass(frozen=True) from __future__ import annotations
class KnownFirmware:
name: str import re
source: str from dataclasses import dataclass, field
sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT
setup_portal: bool # short press -> Wi-Fi setup portal
@dataclass(frozen=True)
class KnownFirmware:
KNOWN: dict[str, KnownFirmware] = { name: str
"45fa9d5b9": KnownFirmware( source: str
name="v1.0 (built Jun 29 2026)", sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT
source="factory image new_system.bin", setup_portal: bool # short press -> Wi-Fi setup portal
sd_wifi=False,
setup_portal=False,
), KNOWN: dict[str, KnownFirmware] = {
} "45fa9d5b9": KnownFirmware(
name="v1.0 (built Jun 29 2026)",
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})") source="factory image new_system.bin",
_VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)") sd_wifi=False,
_BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$") setup_portal=False,
_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)") ),
# Evidence of features, for builds not in KNOWN. "c94ba0e9b": KnownFirmware(
_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ") name="v1.0 source build (Sep 26 2026)",
_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ") source="developer build, built by Krzysztof Kallas",
# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag. sd_wifi=True,
_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted") setup_portal=True,
),
}
@dataclass
class FirmwareInfo: # elf sha (full or prefix) -> (version, compile time, file name) of builds in firmware/
elf_sha: str | None = None LOCAL_BUILDS: dict[str, tuple[str, str, str]] = {}
version: str | None = None
built: str | None = None
idf: str | None = None def register_local_build(elf_sha: str, version: str, built: str, file_name: str) -> None:
evidence: set[str] = field(default_factory=set) LOCAL_BUILDS[elf_sha.lower()] = (version, built, file_name)
def observe(self, text: str) -> None:
if m := _ELF.search(text): def same_build(a: str | None, b: str | None) -> bool:
self.elf_sha = m.group("sha") """ELF SHA prefixes of different lengths (8 from KOTA: boot, 9+ from
elif m := _VER.search(text): app_init, 64 from an image) name the same build if they agree."""
self.version = m.group("v") if not a or not b:
elif m := _BUILT.search(text): return False
self.built = m.group("t").strip() n = min(len(a), len(b))
elif m := _IDF.search(text): return n >= 6 and a[:n].lower() == b[:n].lower()
self.idf = m.group("v")
if _FEATURE_SD_WIFI.search(text):
self.evidence.add("sd_wifi") def local_build(elf_sha: str | None) -> tuple[str, str, str] | None:
if _FEATURE_PORTAL.search(text): return next((v for sha, v in LOCAL_BUILDS.items() if same_build(sha, elf_sha)), None)
self.evidence.add("setup_portal")
if _LEGACY_SD_TAG.search(text):
self.evidence.add("legacy_v1") _ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
_VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)")
@property _BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$")
def known(self) -> KnownFirmware | None: _IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\S+)")
if not self.elf_sha: # Evidence of features, for builds not in KNOWN.
return None _FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ")
return next((k for sha, k in KNOWN.items() if self.elf_sha.startswith(sha)), None) _FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ")
# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag.
@property _LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted")
def supports_sd_wifi(self) -> bool | None:
"""True/False when determinable, None when unknown (no boot seen)."""
if k := self.known: @dataclass
return k.sd_wifi class FirmwareInfo:
if "sd_wifi" in self.evidence: elf_sha: str | None = None
return True version: str | None = None
if "legacy_v1" in self.evidence: built: str | None = None
return False idf: str | None = None
return None hw: str | None = None
evidence: set[str] = field(default_factory=set)
def describe(self) -> str:
if k := self.known: def observe_kota(self, fields: dict[str, str]) -> None:
return f"{k.name} - {k.source}" """'KOTA: boot fw=1.0.16 build=4f10989e hw=2 ...'"""
if self.elf_sha: if fields.get("fw"):
built = f", built {self.built}" if self.built else "" self.version = fields["fw"]
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)" build = fields.get("build", "")
if "legacy_v1" in self.evidence: # app_init's longer prefix is kept if it agrees.
return "Probably v1.0 factory image (press Reset & capture to confirm)" if build and not same_build(self.elf_sha, build):
return "Not identified yet - press Reset & capture" self.elf_sha = build.lower()
if fields.get("hw"):
self.hw = fields["hw"]
self.evidence.add("kota") # 1.0.16+: SD Wi-Fi and setup portal exist
def observe(self, text: str) -> None:
if m := _ELF.search(text):
self.elf_sha = m.group("sha")
elif m := _VER.search(text):
self.version = m.group("v")
elif m := _BUILT.search(text):
self.built = m.group("t").strip()
elif m := _IDF.search(text):
self.idf = m.group("v")
if _FEATURE_SD_WIFI.search(text):
self.evidence.add("sd_wifi")
if _FEATURE_PORTAL.search(text):
self.evidence.add("setup_portal")
if _LEGACY_SD_TAG.search(text):
self.evidence.add("legacy_v1")
@property
def known(self) -> KnownFirmware | None:
if not self.elf_sha:
return None
return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None)
@property
def supports_sd_wifi(self) -> bool | None:
"""True/False when determinable, None when unknown (no boot seen)."""
if k := self.known:
return k.sd_wifi
if "sd_wifi" in self.evidence or "kota" in self.evidence:
return True
if "legacy_v1" in self.evidence:
return False
return None
def describe(self) -> str:
if k := self.known:
return f"{k.name} - {k.source}"
if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)):
local = local_build(self.elf_sha)
built = self.built or (local[1] if local else None)
text = f"v{self.version or (local[0] if local else '?')} (build {self.elf_sha[:8]}"
text += f", compiled {built})" if built else ")"
if self.hw:
text += f", hardware {self.hw}"
if local:
text += f" - same as firmware/{local[2]}"
return text
if self.elf_sha:
built = f", built {self.built}" if self.built else ""
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
if "legacy_v1" in self.evidence:
return "Probably v1.0 factory image (press Reset & capture to confirm)"
return "Not identified yet - press Reset & capture"
+3 -2
View File
@@ -21,7 +21,7 @@ from .engine import LED, DiagnosticsEngine, Severity
GUIDES_PATH = Path(__file__).with_name("guides.toml") GUIDES_PATH = Path(__file__).with_name("guides.toml")
# LED answers a technician can give (engine colours + a reboot-loop pattern). # LED answers a technician can give (engine colours + a reboot-loop pattern).
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "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"}
@@ -57,6 +57,7 @@ class Step:
skip_if_finding: str | None = None skip_if_finding: str | None = None
skip_unless_feature: str | None = None skip_unless_feature: str | None = None
skip_to: str | None = None skip_to: str | None = None
show_qr: bool = False # show the Matter pairing QR next to the step text
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())
@@ -101,7 +102,7 @@ def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)), timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"), on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"), skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
skip_to=s.get("skip_to"), skip_to=s.get("skip_to"), show_qr=bool(s.get("show_qr", False)),
) )
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ()))) ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
for e in g.get("end", [])} for e in g.get("end", [])}
+740 -734
View File
File diff suppressed because it is too large. Load diff
+263
View File
@@ -0,0 +1,263 @@
"""Knowledge about the structured "KOTA:" lines of firmware 1.0.16+.
Firmware source of truth (kota-sensorboard/main):
boot_info.cpp "boot fw=.. build=.. hw=.. mac=.. reset=.. mode=.. commissioned=.. last_restart=.."
"restart reason=.." (W, printed just before a self-restart)
status.cpp "status A -> B [code=N] reason=x", "led color=.. pattern=.. [n=..|period=..ms]",
"led previous_boot status=.. code=..", "led restart_flash color=.."
i2c.cpp "i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307(missing) .. [bus=stuck]"
ds1307.cpp "rtc running=1 time=..", "rtc read_failed err=..", "rtc verify running=1 delta=2s ok=1"
sd_card.cpp "sd type=.. size=.. fs=.. free=.. env_keys=A,B buffer_lines=N", "sd mounted=0 [err=..]"
main.cpp "matter fabrics=N window=open|closed qr=MT:.. manual=.."
microphone.cpp "mic enabled=0 (GPIO3=1, expected 0)"
health.cpp "health up=60s status=OK wifi=connected rssi=-61 time_valid=1 sd=ok buffer=0
heap=123456 psram=7.8M last_upload=12s|never ota=registered|unregistered|selfcheck"
Error IDs ("[E-SD-03] ...") come from error_ids.csv, a copy of the
firmware's docs/error_ids.csv (the firmware build generates its IDs from it).
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from pathlib import Path
ERROR_IDS_PATH = Path(__file__).with_name("error_ids.csv")
# Firmware colour names (status.cpp colors[]) -> engine LED keys.
LED_COLOURS = {
"green": "GREEN", "cyan": "CYAN", "white": "WHITE", "red": "RED",
"blue": "BLUE", "purple": "PURPLE", "yellow": "YELLOW", "orange": "ORANGE",
}
RED_CODES = {1: "BME688", 2: "SEN66", 3: "AS7341", 4: "Microphone", 5: "Flicker sensor", 6: "Self-check"}
I2C_DEVICES = {"0x39": "AS7341", "0x68": "DS1307", "0x6B": "SEN66", "0x76": "BME688"}
# last_restart values written by kota_restart() that are part of normal
# operation (button presses, a finished setup, an installed update).
INTENTIONAL_RESTARTS = {
"ota_update", "button_provisioning_abort", "button_wifi_cleared",
"button_provisioning_toggle", "provisioning_saved",
}
MODULES = {
"SD": "SD card", "ENV": "ENV.TXT", "INF": "InfluxDB upload", "OTA": "Firmware update",
"WIFI": "Wi-Fi", "PROV": "Wi-Fi setup", "MAIN": "Start-up", "STAT": "Status LED", "SPI": "SPI bus",
"RTC": "Clock (RTC)", "BME": "BME688", "SEN": "SEN66", "AS": "AS7341", "MIC": "Microphone",
"FLK": "Flicker sensor", "DEV": "Device", "GATH": "Sensor readings",
}
@dataclass(frozen=True)
class ErrorId:
id: str
module: str
severity: str # error | warning | retired
message: str
action: str
@property
def module_name(self) -> str:
return MODULES.get(self.module, self.module)
def load_error_ids(path: Path = ERROR_IDS_PATH) -> dict[str, ErrorId]:
if not path.exists():
return {}
with path.open(encoding="utf-8", newline="") as fh:
return {r["id"]: ErrorId(r["id"], r["module"], r["severity"], r["message"], r["action"])
for r in csv.DictReader(fh)}
def led_for_status(status: str, code: int) -> tuple[str, str]:
"""(LED key, pattern text) for a status - mirrors Status::pattern_for().
Used when the box's own "led" line was not captured (connected mid-run)."""
if status == "OK":
return "GREEN", "solid"
if status == "ERROR":
return ("RED", f"{code} pulses") if 1 <= code <= 6 else ("RED", "solid")
if status == "OUT_OF_RESOURCES":
return "RED", "solid"
if status == "UPDATING":
return "YELLOW", "blinking"
if status == "SELF_CHECK":
return "ORANGE", "solid"
if status == "CONNECTION_ERROR":
return ("PURPLE", "blinking") if code == 1 else ("PURPLE", "solid")
if status == "RTC_ERROR":
return "BLUE", "2 pulses" if code == 2 else "1 pulse"
if status == "SD_ERROR":
return "WHITE", "fast blinking"
if status == "RESETING":
return "WHITE", "solid"
if status == "HTTP_SERVER":
return "WHITE", "breathing"
return "UNKNOWN", ""
def pattern_text(fields: dict[str, str]) -> str:
"""'led color=red pattern=pulses n=2' -> '2 pulses'."""
p = fields.get("pattern", "")
if p == "pulses":
n = fields.get("n", "?")
return f"{n} pulse" if n == "1" else f"{n} pulses"
if p == "blink":
period = fields.get("period", "").removesuffix("ms")
fast = period.isdigit() and int(period) <= 500
return ("fast blinking" if fast else "blinking") + (f" ({period} ms)" if period else "")
if p == "breathe":
return "breathing"
return p # solid / off
def led_meaning(colour: str, pattern: str) -> str:
"""Technician meaning of a colour + pattern (firmware diagnostics doc)."""
if colour == "GREEN":
return "OK"
if colour == "CYAN":
return "phone connected for pairing" if "fast" in pattern else "waiting to be paired in Matter"
if colour == "WHITE":
if "fast" in pattern:
return "SD card or ENV.TXT error"
if "breath" in pattern:
return "Wi-Fi setup portal"
return "factory reset"
if colour == "RED":
if "pulse" in pattern:
n = int(pattern.split()[0]) if pattern.split()[0].isdigit() else 0
return f"{RED_CODES.get(n, 'sensor')} failed"
return "internal error / out of memory"
if colour == "BLUE":
return "I2C bus stuck (sensor cable)" if pattern.startswith("2") else "clock (RTC) not responding"
if colour == "PURPLE":
return "server rejected the box (check ENV.TXT credentials)" if "blink" in pattern else "no network"
if colour == "YELLOW":
return "downloading a firmware update"
if colour == "ORANGE":
return "self-check after an update"
return ""
# Finding for the current firmware status (Status::set_status). key suffix ->
# (severity, title, meaning, actions, explained_by). The status finding is
# grouped under a more specific finding when one explains it.
_SENSOR_ROOTS = ("sensor.init_failed", "sensor.selfcheck_failed", "kota.i2c_stuck", "kota.sensor_missing",
"sen66.not_ready", "bme688.read_fail", "as7341.read_fail", "mic.read_fail",
"ota.selfcheck_failed")
_NET_ROOTS = ("matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal", "kota.wifi_down",
"net.dns_fail", "net.tls_fail")
def status_finding(status: str, code: int) -> tuple[str, str, str, str, tuple[str, ...], tuple[str, ...]] | None:
"""(key, severity, title, meaning, actions, explained_by) or None for healthy states."""
if status == "ERROR":
if 1 <= code <= 5:
s = RED_CODES[code]
return (f"status.error.{code}", "critical", f"{s} fault (LED red, {code} pulses)",
f"The firmware reports the {s} as failed.",
(f"Open the case and check the {s} connector/cable.",
"If several sensors fail: check the shared I2C cable.",
"The box restarts itself after 150 s in this state."), _SENSOR_ROOTS)
if code == 6:
return ("status.error.6", "error", "Post-update self-check failed (LED red, 6 pulses)",
"The new firmware failed its self-check and rolls back.",
("Check the sensor named in the log (inside the case), then power-cycle.",), _SENSOR_ROOTS)
return ("status.error", "error", "Internal error (LED red)",
"The firmware is in its general error state.",
("Save the session log and send it to the firmware team.",
"The box restarts itself after 150 s in this state."), _SENSOR_ROOTS)
if status == "OUT_OF_RESOURCES":
return ("status.oom", "error", "Firmware ran out of memory (LED red)",
"The firmware could not allocate memory; it restarts itself after 30 s.",
("Power-cycle. If it repeats, save the session log for the firmware team.",),
("sys.out_of_resources",))
if status == "CONNECTION_ERROR":
if code == 1:
return ("status.conn.1", "critical", "Server rejected the box (LED purple, blinking)",
"The network works but the server refused the box (HTTP 401/403/404): "
"INFLUX_URL / INFLUX_TOKEN in ENV.TXT are wrong or revoked.",
("Re-provision the SD card with the current ENV.TXT (SD card is inside the case).",),
("influx.auth", "influx.not_found"))
return ("status.conn", "error", "No connection to the servers (LED purple)",
"Uploads or the update check fail: no Wi-Fi, no Internet or the server is unreachable.",
("Check the box is paired/connected to Wi-Fi and the network has Internet.",),
_NET_ROOTS + ("influx.buffer_fail", "ota.check_failed", "influx.server_error"))
if status == "RTC_ERROR":
if code == 2:
return ("status.rtc.2", "critical", "I2C bus stuck (LED blue, 2 pulses)",
"SDA or SCL is held low: a shorted or broken sensor cable, or a faulty sensor.",
("Open the case and check the I2C cable between the boards.",
"Unplug sensors one by one to find the one holding the bus."), ("kota.i2c_stuck",))
return ("status.rtc.1", "critical", "Clock chip (RTC) not responding (LED blue, 1 pulse)",
"The DS1307 clock does not answer on the I2C bus.",
("Open the case and check the I2C cable/connector between the boards.",
"If the cable is fine: the RTC module is faulty."),
("rtc.init_failed", "kota.rtc_read_failed", "kota.sensor_missing"))
if status == "SD_ERROR":
return ("status.sd", "critical", "SD card or ENV.TXT error (LED white, fast blinking)",
"The SD card cannot be read or ENV.TXT is missing settings.",
("Open the case, re-seat the SD card or re-provision it with the tool.",),
("kota.sd_unmounted", "influx.env_missing", "sd.card_fail", "sd.mount_fail",
"sd.env_open_fail", "influx.init_fail"))
if status == "RESETING":
return ("status.reset", "warning", "Factory reset in progress (LED white, solid)",
"The Matter button was held: the box forgets its Matter network.",
("Pair the box again in the Matter app afterwards.",), ("sys.factory_reset",))
if status == "HTTP_SERVER":
return ("status.portal", "warning", "Box is in Wi-Fi setup mode (LED white, breathing)",
"The box hosts its own Wi-Fi network for configuration.",
("Connect a phone to the box's network and open http://sensorbox.local/,",
"or short-press the Matter button to leave setup mode."), ("wifi.setup_portal",))
return None # OK, UPDATING, SELF_CHECK: covered by milestones / OTA rules
# last_restart -> (severity, title, meaning, actions); None = not a fault.
def restart_finding(reason: str) -> tuple[str, str, str, tuple[str, ...]] | None:
if reason in INTENTIONAL_RESTARTS or reason in ("none", "", "-"):
return None
if reason == "status_watchdog":
return ("warning", "Box restarted itself after 150 s in the red error state",
"By design the firmware restarts when it stays in the error state (red) for 150 s.",
("Fix the grouped cause; the restarts stop by themselves.",))
if reason == "status_oom_watchdog":
return ("error", "Box restarted itself because it ran out of memory",
"The firmware was out of memory for 30 s and restarted.",
("Save the session log for the firmware team.",))
if reason == "provisioning_failed":
return ("warning", "Wi-Fi setup portal failed to start",
"The setup portal could not start; the box cleared setup mode and restarted.",
("Retry Wi-Fi setup (short button press); restart the device if it persists.",))
if reason == "panic":
return ("critical", "Firmware crashed (panic) and restarted",
"The firmware hit a fatal error.",
("Press Reset & capture to record the crash; save the session log for the firmware team.",))
if reason in ("task_wdt", "int_wdt", "wdt"):
return ("error", "Firmware froze and was restarted by the watchdog",
f"The firmware stopped responding ({reason}); the watchdog restarted it.",
("Save the session log for the firmware team.",))
if reason == "brownout":
return ("error", "Power supply voltage dropped (brown-out)",
"The supply voltage fell too low and the chip reset itself.",
("Check the power supply, cable and connectors.",))
if reason == "unknown_sw":
return ("warning", "Unexpected software restart",
"The firmware restarted without recording a reason.",
("Watch whether it repeats; save the session log for the firmware team.",))
return ("warning", f"Box restarted itself ({reason})",
f"The firmware recorded the restart reason '{reason}'.",
("Save the session log for the firmware team if it repeats.",))
def parse_size_gb(value: str) -> float | None:
"""'7.3GB' -> 7.3 (sd_card.cpp format_gb)."""
v = value.upper().removesuffix("GB")
try:
return float(v)
except ValueError:
return None
def parse_seconds(value: str) -> int | None:
v = value.removesuffix("s")
return int(v) if v.lstrip("-").isdigit() else None
+732 -572
View File
File diff suppressed because it is too large. Load diff
+260 -286
View File
@@ -1,286 +1,260 @@
"""Firmware images: validation, integrity and layout. """Firmware images: validation, integrity, layout and the firmware folder.
Two origins: The tool flashes ONLY the .bin files in its own firmware/ folder (next to
* factory - bundled with the tool (firmware/firmware.toml), SHA-256 locked; kota_tech/). There is no import and no per-user library: to offer a build,
* developer - a newer build the firmware team sends to technicians, the firmware team puts the file into that folder. Images are listed by the
imported into a per-user library (~/KotaTechnicianTool/firmware). compile date stored in the firmware itself (ESP-IDF app descriptor), newest
Two kinds: first, so the file name does not matter.
* merged - bootloader at 0x0 + partition table + app (like new_system.bin);
App-only or Full flashing; Two kinds:
* app - the application binary only (build/kota-sensorboard.bin); * merged - bootloader at 0x0 + partition table + app (merged-binary.bin);
App-only flashing, laid out like the factory image. App-only or Full flashing;
* app - the application binary only (build/kota-sensorboard.bin);
Every image is checked structurally (ESP32-S3, kota-sensorboard app App-only flashing, laid out like a merged image in the folder.
descriptor, segment checksum and the appended SHA-256) so a truncated or
corrupted file is refused before anything is written. Every image is checked structurally (ESP32-S3, kota-sensorboard app
""" descriptor, segment checksum and the appended SHA-256) so a truncated or
corrupted file is refused before anything is written.
from __future__ import annotations """
import datetime as dt from __future__ import annotations
import hashlib
import json import datetime as dt
import shutil import hashlib
import struct import struct
import tomllib from dataclasses import dataclass
from dataclasses import dataclass from pathlib import Path
from pathlib import Path
FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware"
FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware"
MANIFEST = FIRMWARE_DIR / "firmware.toml" PT_OFFSET = 0x8000
LIBRARY_DIR = Path.home() / "KotaTechnicianTool" / "firmware" PT_SIZE = 0xC00
LIBRARY_INDEX = "library.json" ESP32S3_CHIP_ID = 9
IMAGE_MAGIC = 0xE9
PT_OFFSET = 0x8000 APP_DESC_MAGIC = 0xABCD5432
PT_SIZE = 0xC00 FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"}
ESP32S3_CHIP_ID = 9 _MONTHS = {m: i for i, m in enumerate(
IMAGE_MAGIC = 0xE9 ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 1)}
APP_DESC_MAGIC = 0xABCD5432
FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"}
_MONTHS = {m: i for i, m in enumerate( class ImageError(ValueError):
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 1)} pass
class ImageError(ValueError): @dataclass(frozen=True)
pass class Partition:
label: str
type: int
@dataclass(frozen=True) subtype: int
class Partition: offset: int
label: str size: int
type: int
subtype: int
offset: int @dataclass(frozen=True)
size: int class AppInfo:
project: str
version: str
@dataclass(frozen=True) built: str # "Sep 28 2026 15:07:57" as in the app descriptor
class AppInfo: idf: str
project: str elf_sha: str # hex
version: str
built: str # "Jun 29 2026 11:03:32" as in the app descriptor @property
idf: str def built_at(self) -> dt.datetime | None:
elf_sha: str # hex try:
mon, day, year, hms = self.built.split()
@property h, m, s = (int(x) for x in hms.split(":"))
def built_at(self) -> dt.datetime | None: return dt.datetime(int(year), _MONTHS[mon], int(day), h, m, s)
try: except (ValueError, KeyError):
mon, day, year, hms = self.built.split() return None
h, m, s = (int(x) for x in hms.split(":"))
return dt.datetime(int(year), _MONTHS[mon], int(day), h, m, s) @property
except (ValueError, KeyError): def compiled(self) -> str:
return None """'2026-09-28 15:07' (ISO-like, sortable) or the raw descriptor text."""
t = self.built_at
return t.strftime("%Y-%m-%d %H:%M") if t else self.built
@dataclass(frozen=True)
class FirmwareImage:
path: Path @dataclass(frozen=True)
name: str class FirmwareImage:
notes: str path: Path
origin: str # "factory" | "developer" kind: str # "merged" | "app"
kind: str # "merged" | "app" sha256: str
sha256: str flash_size: str
flash_size: str partitions: tuple[Partition, ...]
partitions: tuple[Partition, ...] partition_table: bytes # expected table on the box
partition_table: bytes # expected table on the box app_offset: int # where the app starts inside the file
app_offset: int # where the app starts inside the file app_length: int # exact app image length (from its structure)
app_length: int # exact app image length (from its structure) app: AppInfo
app: AppInfo
@property
@property def name(self) -> str:
def data(self) -> bytes: return self.path.name
return self.path.read_bytes()
@property
@property def data(self) -> bytes:
def is_developer(self) -> bool: return self.path.read_bytes()
return self.origin == "developer"
def partition(self, label: str) -> Partition:
def partition(self, label: str) -> Partition: for p in self.partitions:
for p in self.partitions: if p.label == label:
if p.label == label: return p
return p raise ImageError(f"partition {label!r} not in image")
raise ImageError(f"partition {label!r} not in image")
def app_bytes(self) -> bytes:
def app_bytes(self) -> bytes: return self.data[self.app_offset:self.app_offset + self.app_length]
return self.data[self.app_offset:self.app_offset + self.app_length]
def blank_otadata(self) -> bytes:
def blank_otadata(self) -> bytes: """Erased otadata: the bootloader then boots ota_0."""
"""Erased otadata: the bootloader then boots ota_0.""" return b"\xff" * self.partition("otadata").size
return b"\xff" * self.partition("otadata").size
def label(self) -> str:
def label(self) -> str: """Choice text: compile date first (that is what the technician picks by)."""
tag = "factory" if self.origin == "factory" else "developer build" kind = "" if self.kind == "merged" else ", app only"
return f"{self.app.version} built {self.app.built} [{self.app.elf_sha[:9]}] - {tag}" return f"Compiled {self.app.compiled} - v{self.app.version} [{self.app.elf_sha[:8]}]{kind} ({self.name})"
# ---------------------------------------------------------------- parsing # ---------------------------------------------------------------- parsing
def parse_partition_table(raw: bytes) -> tuple[Partition, ...]: def parse_partition_table(raw: bytes) -> tuple[Partition, ...]:
parts = [] parts = []
for i in range(0, len(raw) - 31, 32): for i in range(0, len(raw) - 31, 32):
entry = raw[i:i + 32] entry = raw[i:i + 32]
magic = entry[:2] magic = entry[:2]
if magic == b"\xaa\x50": if magic == b"\xaa\x50":
_, ptype, sub, off, size = struct.unpack_from("<HBBII", entry) _, ptype, sub, off, size = struct.unpack_from("<HBBII", entry)
label = entry[12:28].split(b"\0")[0].decode("ascii", "replace") label = entry[12:28].split(b"\0")[0].decode("ascii", "replace")
parts.append(Partition(label, ptype, sub, off, size)) parts.append(Partition(label, ptype, sub, off, size))
elif magic in (b"\xeb\xeb", b"\xff\xff"): # MD5 entry / end elif magic in (b"\xeb\xeb", b"\xff\xff"): # MD5 entry / end
break break
else: else:
raise ImageError(f"corrupt partition table entry at 0x{PT_OFFSET + i:x}") raise ImageError(f"corrupt partition table entry at 0x{PT_OFFSET + i:x}")
return tuple(parts) return tuple(parts)
def app_image_length(data: bytes, offset: int = 0) -> int: def app_image_length(data: bytes, offset: int = 0) -> int:
"""Validate an ESP app image at `offset`; return its exact length. """Validate an ESP app image at `offset`; return its exact length.
Checks the segment checksum and, when the header says so, the appended Checks the segment checksum and, when the header says so, the appended
SHA-256 - the same checks the bootloader performs before booting it. SHA-256 - the same checks the bootloader performs before booting it.
""" """
if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC: if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC:
raise ImageError(f"no firmware image at 0x{offset:x}") raise ImageError(f"no firmware image at 0x{offset:x}")
segments = data[offset + 1] segments = data[offset + 1]
chip_id = struct.unpack_from("<H", data, offset + 12)[0] chip_id = struct.unpack_from("<H", data, offset + 12)[0]
if chip_id != ESP32S3_CHIP_ID: if chip_id != ESP32S3_CHIP_ID:
raise ImageError(f"image is for chip id {chip_id}, not ESP32-S3") raise ImageError(f"image is for chip id {chip_id}, not ESP32-S3")
hash_appended = data[offset + 23] == 1 hash_appended = data[offset + 23] == 1
pos, checksum = offset + 24, 0xEF pos, checksum = offset + 24, 0xEF
for _ in range(segments): for _ in range(segments):
if pos + 8 > len(data): if pos + 8 > len(data):
raise ImageError("image is truncated (segment header missing)") raise ImageError("image is truncated (segment header missing)")
_, seg_len = struct.unpack_from("<II", data, pos) _, seg_len = struct.unpack_from("<II", data, pos)
pos += 8 pos += 8
if pos + seg_len > len(data): if pos + seg_len > len(data):
raise ImageError("image is truncated (segment data missing)") raise ImageError("image is truncated (segment data missing)")
for b in data[pos:pos + seg_len]: for b in data[pos:pos + seg_len]:
checksum ^= b checksum ^= b
pos += seg_len pos += seg_len
pos += 15 - ((pos - offset) % 16) # pad so the checksum ends a 16-byte block pos += 15 - ((pos - offset) % 16) # pad so the checksum ends a 16-byte block
if pos >= len(data) or data[pos] != checksum: if pos >= len(data) or data[pos] != checksum:
raise ImageError("image checksum is wrong - the file is corrupted") raise ImageError("image checksum is wrong - the file is corrupted")
pos += 1 pos += 1
if hash_appended: if hash_appended:
if pos + 32 > len(data): if pos + 32 > len(data):
raise ImageError("image is truncated (SHA-256 missing)") raise ImageError("image is truncated (SHA-256 missing)")
if hashlib.sha256(data[offset:pos]).digest() != data[pos:pos + 32]: if hashlib.sha256(data[offset:pos]).digest() != data[pos:pos + 32]:
raise ImageError("image SHA-256 does not match its content - the file is corrupted") raise ImageError("image SHA-256 does not match its content - the file is corrupted")
pos += 32 pos += 32
return pos - offset return pos - offset
def _app_info(data: bytes, offset: int) -> AppInfo: def _app_info(data: bytes, offset: int) -> AppInfo:
d = data[offset + 0x20:offset + 0x20 + 256] d = data[offset + 0x20:offset + 0x20 + 256]
if len(d) < 256 or struct.unpack_from("<I", d)[0] != APP_DESC_MAGIC: if len(d) < 256 or struct.unpack_from("<I", d)[0] != APP_DESC_MAGIC:
raise ImageError("firmware has no ESP-IDF app descriptor") raise ImageError("firmware has no ESP-IDF app descriptor")
s = lambda o, n: d[o:o + n].split(b"\0")[0].decode("ascii", "replace") s = lambda o, n: d[o:o + n].split(b"\0")[0].decode("ascii", "replace")
info = AppInfo(project=s(48, 32), version=s(16, 32), built=f"{s(96, 16)} {s(80, 16)}", info = AppInfo(project=s(48, 32), version=s(16, 32), built=f"{s(96, 16)} {s(80, 16)}",
idf=s(112, 32), elf_sha=d[144:176].hex()) idf=s(112, 32), elf_sha=d[144:176].hex())
if info.project != "kota-sensorboard": if info.project != "kota-sensorboard":
raise ImageError(f"firmware is {info.project!r}, not kota-sensorboard") raise ImageError(f"firmware is {info.project!r}, not kota-sensorboard")
return info return info
def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "factory",
reference: FirmwareImage | None = None) -> FirmwareImage: def inspect_image(path: Path, reference: FirmwareImage | None = None) -> FirmwareImage:
"""Validate a merged or app-only image. App-only images need a `reference` """Validate a merged or app-only image. App-only images need a `reference`
(the factory image) that provides the flash layout.""" (a merged image) that provides the flash layout."""
data = path.read_bytes() data = path.read_bytes()
if len(data) >= PT_OFFSET + PT_SIZE and data[0] == IMAGE_MAGIC and data[PT_OFFSET:PT_OFFSET + 2] == b"\xaa\x50": if len(data) >= PT_OFFSET + PT_SIZE and data[0] == IMAGE_MAGIC and data[PT_OFFSET:PT_OFFSET + 2] == b"\xaa\x50":
app_image_length(data, 0) # bootloader must be intact app_image_length(data, 0) # bootloader must be intact
table = data[PT_OFFSET:PT_OFFSET + PT_SIZE] table = data[PT_OFFSET:PT_OFFSET + PT_SIZE]
parts = parse_partition_table(table) parts = parse_partition_table(table)
labels = {p.label for p in parts} labels = {p.label for p in parts}
for need in ("nvs", "otadata", "ota_0"): for need in ("nvs", "otadata", "ota_0"):
if need not in labels: if need not in labels:
raise ImageError(f"partition table has no {need!r} partition") raise ImageError(f"partition table has no {need!r} partition")
ota0 = next(p for p in parts if p.label == "ota_0") ota0 = next(p for p in parts if p.label == "ota_0")
kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?") kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?")
elif data[:1] == bytes([IMAGE_MAGIC]): elif data[:1] == bytes([IMAGE_MAGIC]):
if reference is None: if reference is None:
raise ImageError("app-only image needs the factory image as layout reference") raise ImageError("app-only file, but there is no complete (merged) image in the folder "
kind, app_off = "app", 0 "to take the flash layout from")
parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size kind, app_off = "app", 0
ota0 = next(p for p in parts if p.label == "ota_0") parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size
else: ota0 = next(p for p in parts if p.label == "ota_0")
raise ImageError("not an ESP32 firmware file") else:
length = app_image_length(data, app_off) raise ImageError("not an ESP32 firmware file")
if length > ota0.size: length = app_image_length(data, app_off)
raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)") if length > ota0.size:
app = _app_info(data, app_off) raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)")
return FirmwareImage(path, name or path.name, notes, origin, kind, hashlib.sha256(data).hexdigest(), app = _app_info(data, app_off)
flash_size, parts, table, app_off, length, app) return FirmwareImage(path, kind, hashlib.sha256(data).hexdigest(),
flash_size, parts, table, app_off, length, app)
# ------------------------------------------------------------- catalogue
# ------------------------------------------------------------- catalogue
def factory_images(manifest: Path = MANIFEST) -> list[FirmwareImage]:
"""Bundled images whose file exists and matches the SHA-256 in the manifest.""" def folder_images(folder: Path = FIRMWARE_DIR) -> tuple[list[FirmwareImage], list[str]]:
if not manifest.exists(): """(valid images newest-compiled first, problems) for the .bin files in `folder`.
return []
out = [] A broken or foreign file is reported, never offered. The same build
for e in tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", []): under two file names is offered once.
path = manifest.parent / e["file"] """
if not path.exists(): if not folder.is_dir():
continue return [], [f"firmware folder not found: {folder}"]
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "factory") files = sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == ".bin")
if img.sha256 != e["sha256"].lower(): merged: list[FirmwareImage] = []
raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt") app_only: list[Path] = []
out.append(img) problems: list[str] = []
return out for f in files:
try:
merged.append(inspect_image(f))
class FirmwareLibrary: except ImageError as exc:
"""Developer builds imported by the technician (persist across tool versions).""" if "app-only" in str(exc):
app_only.append(f)
def __init__(self, root: Path = LIBRARY_DIR): else:
self.root = root problems.append(f"{f.name}: {exc}")
except OSError as exc:
def _index(self) -> list[dict]: problems.append(f"{f.name}: cannot read ({exc})")
f = self.root / LIBRARY_INDEX merged.sort(key=_newest_first)
return json.loads(f.read_text(encoding="utf-8")) if f.exists() else [] reference = merged[0] if merged else None
images = list(merged)
def _save(self, entries: list[dict]) -> None: for f in app_only:
self.root.mkdir(parents=True, exist_ok=True) try:
(self.root / LIBRARY_INDEX).write_text(json.dumps(entries, indent=2), encoding="utf-8") images.append(inspect_image(f, reference))
except (ImageError, OSError) as exc:
def images(self, reference: FirmwareImage | None) -> tuple[list[FirmwareImage], list[str]]: problems.append(f"{f.name}: {exc}")
"""(valid images, problems) - a changed or broken file is reported, not offered.""" images.sort(key=_newest_first)
imgs, problems = [], [] unique: dict[str, FirmwareImage] = {}
for e in self._index(): for img in images:
path = self.root / e["file"] if img.sha256 in unique:
try: problems.append(f"{img.name}: same file as {unique[img.sha256].name} (listed once)")
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "developer", reference) else:
if img.sha256 != e["sha256"]: unique[img.sha256] = img
raise ImageError("file changed since it was imported") return list(unique.values()), problems
imgs.append(img)
except (OSError, ImageError) as exc:
problems.append(f"{e.get('original', e['file'])}: {exc}") def _newest_first(img: FirmwareImage) -> tuple:
return imgs, problems # Unknown compile dates last; merged before app-only for the same date.
t = img.app.built_at
def import_file(self, src: Path, reference: FirmwareImage | None, notes: str = "") -> FirmwareImage: return (t is None, -t.timestamp() if t else 0.0, img.kind != "merged", img.name)
img = inspect_image(src, origin="developer", reference=reference) # validate before copying
entries = self._index()
existing = next((e for e in entries if e["sha256"] == img.sha256), None)
if existing:
return inspect_image(self.root / existing["file"], existing["name"], existing["notes"],
"developer", reference)
self.root.mkdir(parents=True, exist_ok=True)
fname = f"{img.app.elf_sha[:9]}_{img.sha256[:8]}.bin"
shutil.copyfile(src, self.root / fname)
name = f"Developer build {img.app.version} ({img.app.built})"
entries.append({"file": fname, "sha256": img.sha256, "name": name, "original": src.name,
"imported": dt.datetime.now().isoformat(timespec="seconds"), "notes": notes})
self._save(entries)
return inspect_image(self.root / fname, name, notes, "developer", reference)
def remove(self, sha256: str) -> None:
entries = self._index()
keep = [e for e in entries if e["sha256"] != sha256]
for e in entries:
if e["sha256"] == sha256:
(self.root / e["file"]).unlink(missing_ok=True)
self._save(keep)
# Backwards-compatible name used by earlier callers/tests.
official_images = factory_images
View File
Whitespace-only changes.
+113
View File
@@ -0,0 +1,113 @@
"""Matter onboarding payloads: the "MT:..." QR string and the 11-digit manual code.
Implements the Matter spec (5.1.3 QR payload, 5.1.4 manual pairing code):
bit fields packed LSB-first, Base38 in 3-byte chunks, Verhoeff check digit.
The sensorbox firmware uses the Matter SDK example commissionable data
(CONFIG_EXAMPLE_COMMISSIONABLE_DATA_PROVIDER): every v1.0 box advertises
VID 0xFFF1 / PID 0x8000 / discriminator 3840 with passcode 20202021, which is
exactly the sticker (QR "MT:Y.K9042C00KA0648G00", manual 3497-011-2332).
VID/PID/discriminator are taken from the box's own log when available; the
passcode is never logged, so the known default is used.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
BASE38 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-."
RENDEZVOUS_BLE = 1 << 1
DEFAULT_PASSCODE = 20202021 # Matter SDK test passcode used by the v1.0 firmware
DEFAULT = dict(vendor_id=0xFFF1, product_id=0x8000, discriminator=3840)
# chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
_ADVERTISE = re.compile(r"vendorID=(\d+) productID=(\d+) discriminator=(\d+)")
@dataclass(frozen=True)
class SetupPayload:
vendor_id: int = DEFAULT["vendor_id"]
product_id: int = DEFAULT["product_id"]
discriminator: int = DEFAULT["discriminator"]
passcode: int = DEFAULT_PASSCODE
rendezvous: int = RENDEZVOUS_BLE
flow: int = 0 # standard commissioning flow
version: int = 0
from_log: bool = False # VID/PID/discriminator read from this box's log
qr: str | None = None # exact payload reported by the box ("KOTA: matter qr=MT:...")
manual: str | None = None # exact 11-digit manual code reported by the box
@property
def from_box(self) -> bool:
return self.qr is not None
def qr_text(self) -> str:
if self.qr:
return self.qr
fields = [(self.version, 3), (self.vendor_id, 16), (self.product_id, 16), (self.flow, 2),
(self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)]
value, shift = 0, 0
for v, bits in fields:
if not 0 <= v < (1 << bits):
raise ValueError(f"field value {v} does not fit in {bits} bits")
value |= v << shift
shift += bits
return "MT:" + _base38(value.to_bytes(shift // 8, "little"))
def manual_code(self) -> str:
if self.manual:
return self.manual
short = self.discriminator >> 8 # upper 4 bits
chunk1 = short >> 2 # vid_pid_present = 0
chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF)
chunk3 = self.passcode >> 14
digits = f"{chunk1}{chunk2:05d}{chunk3:04d}"
return digits + str(verhoeff_check(digits))
def manual_code_pretty(self) -> str:
c = self.manual_code()
return f"{c[:4]}-{c[4:7]}-{c[7:]}"
def _base38(data: bytes) -> str:
out = []
for i in range(0, len(data), 3):
chunk = data[i:i + 3]
value = int.from_bytes(chunk, "little")
for _ in range({3: 5, 2: 4, 1: 2}[len(chunk)]):
value, rem = divmod(value, 38)
out.append(BASE38[rem])
return "".join(out)
_D = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]]
_P = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8]]
_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]
def verhoeff_check(digits: str) -> int:
c = 0
for i, d in enumerate(reversed(digits)):
c = _D[c][_P[(i + 1) % 8][int(d)]]
return _INV[c]
def payload_from_log(lines: list[str], qr: str | None = None, manual: str | None = None) -> SetupPayload:
"""Firmware 1.0.16+ reports its exact QR payload and manual code
("KOTA: matter ... qr=MT:... manual=..."); older firmware: use the
advertised VID/PID/discriminator if seen; defaults otherwise."""
base = SetupPayload()
for text in reversed(lines):
if m := _ADVERTISE.search(text):
base = SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True)
break
if qr:
return SetupPayload(base.vendor_id, base.product_id, base.discriminator, from_log=True,
qr=qr, manual=manual if manual and manual.isdigit() else None)
return base
+4 -4
View File
@@ -111,10 +111,10 @@ def parse_macos(list_plist: bytes, infos: dict[str, bytes]) -> list[Disk]:
def list_cards() -> tuple[list[Disk], list[Disk]]: def list_cards() -> tuple[list[Disk], list[Disk]]:
"""(cards that may be formatted, readers/disks shown but not offered).""" """(cards that may be formatted, readers/disks shown but not offered)."""
if sys.platform == "win32": if sys.platform == "win32":
out = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", WIN_LIST_PS], # Driver-level listing (milliseconds, 1.5 s worst case) instead of
capture_output=True, text=True, timeout=90, # Get-Disk/WMI, which stalled 5-21 s on an empty card-reader slot.
creationflags=subprocess.CREATE_NO_WINDOW).stdout from .win_disks import list_disks
disks = parse_windows(out) disks = list_disks(CARD_BUSES)
elif sys.platform == "darwin": elif sys.platform == "darwin":
lst = subprocess.run(["diskutil", "list", "-plist", "physical"], capture_output=True, timeout=30).stdout lst = subprocess.run(["diskutil", "list", "-plist", "physical"], capture_output=True, timeout=30).stdout
infos = {} infos = {}
+135
View File
@@ -0,0 +1,135 @@
"""FAT32 + MBR layout for an SD card, as raw byte writes (no OS formatter).
Follows the Microsoft FAT specification (fatgen103): BPB at sector 0,
FSInfo at 1, backup boot sector at 6, two FATs, root directory in cluster 2
holding the volume label. Cluster sizes follow Windows' defaults. The
partition starts at 1 MiB (SD erase-block friendly), MBR type 0x0C
(FAT32 LBA), which is what the firmware's FatFs expects.
Pure functions: the result is a list of (offset, bytes) writes plus zero
ranges relative to the start of the DISK, so it can be tested on a buffer.
"""
from __future__ import annotations
import math
import struct
from dataclasses import dataclass
PART_START = 1024 * 1024
FAT32_MAX_BYTES = 32 * 1024**3 # Windows convention, and all the box needs
MIN_CLUSTERS, MAX_CLUSTERS = 65525, 0x0FFFFFF5
RESERVED = 32
NUM_FATS = 2
MEDIA = 0xF8
@dataclass(frozen=True)
class Fat32Layout:
bytes_per_sector: int
part_start: int # bytes from disk start
part_sectors: int
sectors_per_cluster: int
fat_sectors: int
clusters: int
@property
def data_start(self) -> int: # sectors from partition start
return RESERVED + NUM_FATS * self.fat_sectors
def _spc(sectors: int, bps: int) -> int:
size = sectors * bps
for limit, cluster in ((260 * 1024**2, 512), (8 * 1024**3, 4096), (16 * 1024**3, 8192),
(32 * 1024**3, 16384)):
if size <= limit:
return max(1, cluster // bps)
return max(1, 32768 // bps)
def plan(disk_bytes: int, bps: int = 512) -> Fat32Layout:
part_bytes = min(disk_bytes - PART_START, FAT32_MAX_BYTES)
part_bytes -= part_bytes % (1024 * 1024) # whole MiB
sectors = part_bytes // bps
if sectors <= 0:
raise ValueError("card is too small")
spc = _spc(sectors, bps)
fat = 1
for _ in range(20): # converge FAT size <-> cluster count
clusters = (sectors - RESERVED - NUM_FATS * fat) // spc
need = math.ceil((clusters + 2) * 4 / bps)
if need == fat:
break
fat = need
clusters = (sectors - RESERVED - NUM_FATS * fat) // spc
if not MIN_CLUSTERS <= clusters < MAX_CLUSTERS:
raise ValueError(f"card size gives {clusters} clusters - not valid for FAT32")
return Fat32Layout(bps, PART_START, sectors, spc, fat, clusters)
def _boot_sector(lay: Fat32Layout, label: bytes, vol_id: int) -> bytes:
bps = lay.bytes_per_sector
b = bytearray(bps)
b[0:3] = b"\xEB\x58\x90"
b[3:11] = b"MSWIN4.1"
struct.pack_into("<HBHBHHBHHHII", b, 11, bps, lay.sectors_per_cluster, RESERVED, NUM_FATS, 0, 0,
MEDIA, 0, 63, 255, lay.part_start // bps, lay.part_sectors)
struct.pack_into("<IHHIHH", b, 36, lay.fat_sectors, 0, 0, 2, 1, 6)
struct.pack_into("<BBBI", b, 64, 0x80, 0, 0x29, vol_id)
b[71:82] = label
b[82:90] = b"FAT32 "
b[90:93] = b"\xF4\xEB\xFD" # not bootable: hlt loop
b[510:512] = b"\x55\xAA"
return bytes(b)
def _fsinfo(lay: Fat32Layout) -> bytes:
b = bytearray(lay.bytes_per_sector)
struct.pack_into("<I", b, 0, 0x41615252)
struct.pack_into("<III", b, 484, 0x61417272, lay.clusters - 1, 3) # root uses cluster 2
struct.pack_into("<I", b, 508, 0xAA550000)
return bytes(b)
def mbr(lay: Fat32Layout, signature: int) -> bytes:
b = bytearray(512)
struct.pack_into("<I", b, 0x1B8, signature)
entry = struct.pack("<B3sB3sII", 0x00, b"\xFE\xFF\xFF", 0x0C, b"\xFE\xFF\xFF",
lay.part_start // lay.bytes_per_sector, lay.part_sectors)
b[0x1BE:0x1BE + 16] = entry
b[510:512] = b"\x55\xAA"
return bytes(b[:512]) + bytes(lay.bytes_per_sector - 512)
def volume_label(text: str) -> bytes:
label = text.upper().encode("ascii", "replace")[:11]
return label.ljust(11, b" ")
def build(lay: Fat32Layout, label: str, vol_id: int, disk_signature: int,
disk_bytes: int) -> tuple[list[tuple[int, int]], list[tuple[int, bytes]]]:
"""(zero ranges, writes) in disk byte offsets; apply zero ranges first."""
bps, p = lay.bytes_per_sector, lay.part_start
lab = volume_label(label)
fat_bytes = lay.fat_sectors * bps
fat1 = p + RESERVED * bps
fat2 = fat1 + fat_bytes
root = p + lay.data_start * bps
cluster = lay.sectors_per_cluster * bps
zeros = [
(0, PART_START), # old MBR / GPT header and gap
(disk_bytes - PART_START, PART_START), # backup GPT at the end of the disk
(p, RESERVED * bps), # reserved region
(fat1, NUM_FATS * fat_bytes), # both FATs
(root, cluster), # root directory cluster
]
boot, info = _boot_sector(lay, lab, vol_id), _fsinfo(lay)
first_fat = struct.pack("<III", 0x0FFFFF00 | MEDIA, 0x0FFFFFFF, 0x0FFFFFFF)
label_entry = lab + bytes([0x08]) + bytes(20) # 32-byte volume-label directory entry
writes = [
(p, boot), (p + bps, info), (p + 6 * bps, boot), (p + 7 * bps, info),
(fat1, first_fat), (fat2, first_fat),
(root, label_entry),
(0, mbr(lay, disk_signature)), # last: the partition appears only now
]
return zeros, writes
+261 -273
View File
@@ -1,273 +1,261 @@
"""Format an SD card for the sensorbox and write / check ENV.TXT. """Format an SD card for the sensorbox and write / check ENV.TXT.
Card format required by the firmware: MBR partition table, FAT32 (its FAT Card format required by the firmware: MBR partition table, FAT32 (its FAT
driver has no exFAT and no long file names), ENV.TXT in the root. driver has no exFAT and no long file names), ENV.TXT in the root.
Windows: formatting needs administrator rights, so a generated PowerShell Windows: formatting needs administrator rights, so kota_tech.sd.win_format
script runs elevated (UAC prompt). The script re-checks the disk (number, runs elevated (UAC prompt). It re-checks the disk (number, size, bus, not the
size, bus, not system/boot) right before erasing it, so a card swapped Windows drive) right before erasing it, so a card swapped after the
after the technician confirmed is not touched. Windows cannot format FAT32 technician confirmed is not touched, and it writes MBR + FAT32 itself
above 32 GB, so larger cards get a 32 GB partition (plenty for the box). (no Storage cmdlets, which stalled on empty card-reader slots). Larger cards
macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh). get a 32 GB FAT32 partition (Windows convention; plenty for the box).
""" macOS: `diskutil eraseDisk FAT32 KOTA MBRFormat` (same as format_sd.sh).
"""
from __future__ import annotations
from __future__ import annotations
import json
import os import json
import plistlib import os
import subprocess import plistlib
import sys import subprocess
import time import sys
import uuid import time
from dataclasses import dataclass, field import uuid
from pathlib import Path from dataclasses import dataclass, field
from pathlib import Path
from .disks import CARD_BUSES, Disk
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env from .disks import Disk
from .envfile import BUFFER_NAME, ENV_NAME, EnvCheck, EnvConfig, find_case_insensitive, parse_env
VOLUME_LABEL = "KOTA"
FAT32_MAX = 32 * 1024**3 VOLUME_LABEL = "KOTA"
@dataclass @dataclass
class Outcome: class Outcome:
ok: bool ok: bool
message: str message: str
mount: str = "" mount: str = ""
log: list[str] = field(default_factory=list) log: list[str] = field(default_factory=list)
@dataclass @dataclass
class CardReport: class CardReport:
mount: str mount: str
filesystem: str filesystem: str
env: EnvCheck | None env: EnvCheck | None
buffered_lines: int | None # measurements waiting on the card for upload buffered_lines: int | None # measurements waiting on the card for upload
problems: list[str] problems: list[str]
# ----------------------------------------------------------------- Windows # ----------------------------------------------------------------- Windows
def windows_format_script(disk: Disk, result_file: Path) -> str: WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp"
buses = ",".join(f"'{b}'" for b in sorted(CARD_BUSES))
return f"""
$ErrorActionPreference = 'Stop' def _workdir() -> Path:
$result = @{{ ok = $false; message = ''; drive = '' }} # Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a
try {{ # file written there by the ELEVATED script is then unreadable for this
$n = {int(disk.id)} # (non-elevated) process. A normally inherited ACL works for both.
$d = Get-Disk -Number $n work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}"
if ($d.IsSystem -or $d.IsBoot) {{ throw 'Refusing: this is a system/boot disk.' }} work.mkdir(parents=True, exist_ok=False)
if (@({buses}) -notcontains [string]$d.BusType) {{ throw "Refusing: disk bus is $($d.BusType), not an SD/USB card." }} return work
if ([int64]$d.Size -ne {int(disk.size)}) {{ throw 'Refusing: the disk changed since it was selected (card swapped?).' }}
if ($d.PartitionStyle -ne 'RAW') {{ Clear-Disk -Number $n -RemoveData -RemoveOEM -Confirm:$false }}
Initialize-Disk -Number $n -PartitionStyle MBR -ErrorAction SilentlyContinue def win_format_command(disk: Disk, result: Path) -> tuple[str, str, str]:
$size = [Math]::Min([int64]$d.Size - 2MB, [int64]{FAT32_MAX}) """(program, parameters, working dir) for the elevated helper."""
$p = New-Partition -DiskNumber $n -Size $size -MbrType FAT32 -AssignDriveLetter root = Path(__file__).resolve().parents[2] # the version folder holding kota_tech/
Format-Volume -Partition $p -FileSystem FAT32 -NewFileSystemLabel '{VOLUME_LABEL}' -Confirm:$false -Force | Out-Null params = f'-m kota_tech.sd.win_format --disk {int(disk.id)} --size {int(disk.size)} ' \
Start-Sleep -Seconds 2 f'--label {VOLUME_LABEL} --result "{result}"'
$letter = (Get-Partition -DiskNumber $n -PartitionNumber $p.PartitionNumber).DriveLetter return sys.executable, params, str(root)
if (-not $letter) {{ throw 'Formatted, but Windows gave the card no drive letter.' }}
$result.ok = $true; $result.drive = "$($letter):\\"; $result.message = 'Formatted FAT32 (MBR).'
}} catch {{ $result.message = $_.Exception.Message }} def _run_elevated(program: str, params: str, cwd: str, timeout_s: int = 300) -> int | None:
$result | ConvertTo-Json | Out-File -FilePath '{result_file}' -Encoding utf8 """ShellExecuteEx 'runas' + wait. None if the technician refused the UAC prompt."""
""" import ctypes
from ctypes import wintypes
WORK_ROOT = Path.home() / "KotaTechnicianTool" / "tmp" class SHELLEXECUTEINFOW(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND),
("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR),
def _workdir() -> Path: ("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE),
# Not tempfile.mkdtemp(): on Windows it creates an owner-only folder, and a ("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hkeyClass", wintypes.HKEY),
# file written there by the ELEVATED script is then unreadable for this ("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE)]
# (non-elevated) process. A normally inherited ACL works for both.
work = WORK_ROOT / f"sd_{uuid.uuid4().hex[:10]}" SEE_MASK_NOCLOSEPROCESS, SW_HIDE = 0x40, 0
work.mkdir(parents=True, exist_ok=False) info = SHELLEXECUTEINFOW(cbSize=ctypes.sizeof(SHELLEXECUTEINFOW), fMask=SEE_MASK_NOCLOSEPROCESS,
return work lpVerb="runas", lpFile=program, lpParameters=params, lpDirectory=cwd, nShow=SW_HIDE)
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
def _query_windows_card(number: int) -> tuple[str, str, str]: if not shell32.ShellExecuteExW(ctypes.byref(info)):
"""(drive, filesystem, label) of the card's first partition - no admin rights needed.""" return None # ERROR_CANCELLED (1223) or failure
ps = (f"$p = Get-Partition -DiskNumber {number} -ErrorAction SilentlyContinue | Select-Object -First 1; " k32.WaitForSingleObject(info.hProcess, timeout_s * 1000)
"if ($p) { $v = $p | Get-Volume -ErrorAction SilentlyContinue; " code = wintypes.DWORD()
"\"$($p.DriveLetter)|$($v.FileSystem)|$($v.FileSystemLabel)\" }") k32.GetExitCodeProcess(info.hProcess, ctypes.byref(code))
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True, k32.CloseHandle(info.hProcess)
timeout=90, creationflags=subprocess.CREATE_NO_WINDOW).stdout.strip() return code.value
drive, fs, label = (out.split("|") + ["", "", ""])[:3]
return (f"{drive}:\\" if drive.strip() else ""), fs, label
def _format_windows(disk: Disk, log: list[str]) -> Outcome:
result = _workdir() / "result.json"
def _format_windows(disk: Disk, log: list[str]) -> Outcome: log.append("Asking Windows for administrator rights to format the card (UAC prompt)...")
work = _workdir() t = time.perf_counter()
script, result = work / "format_card.ps1", work / "result.json" code = _run_elevated(*win_format_command(disk, result))
script.write_text(windows_format_script(disk, result), encoding="utf-8-sig") if code is None:
log.append("Asking Windows for administrator rights to format the card (UAC prompt)...") return Outcome(False, "Administrator permission was refused - the card was not formatted.", log=log)
launcher = ("$p = Start-Process powershell -Verb RunAs -Wait -PassThru -WindowStyle Hidden " try:
f"-ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"{script}\"'; exit $p.ExitCode") res = json.loads(result.read_text(encoding="utf-8"))
proc = subprocess.run(["powershell", "-NoProfile", "-Command", launcher], capture_output=True, text=True, except (OSError, ValueError) as exc:
timeout=600, creationflags=subprocess.CREATE_NO_WINDOW) return Outcome(False, f"The formatter did not report back ({exc.__class__.__name__}, exit {code}). "
if not result.exists(): "Check the card with 'Check card'.", log=log)
err = (proc.stderr or "").strip().splitlines() steps = ", ".join(f"{k} {v:.1f} s" for k, v in res.get("timings", {}).items())
cancelled = any("canceled" in e.lower() or "cancelled" in e.lower() for e in err) log.append(f"{res.get('message', '')} ({time.perf_counter() - t:.1f} s incl. permission prompt; {steps})")
return Outcome(False, "Administrator permission was refused - the card was not formatted." if cancelled return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log)
else "Formatting did not run: " + (err[-1] if err else "unknown error"), log=log)
try:
res = json.loads(result.read_text(encoding="utf-8-sig")) def _eject_windows(mount: str) -> bool:
except (OSError, ValueError) as exc: """Flush, lock, dismount and eject the card - what 'Safely Remove' does.
# The script ran but its report is unreadable: judge by the card itself.
log.append(f"Could not read the format result ({exc.__class__.__name__}); checking the card directly.") Not Shell.Application InvokeVerb('Eject'): it fails silently because the
drive, fs, label = _query_windows_card(int(disk.id)) verb is named 'E&ject' (and is localised on non-English Windows).
ok = bool(drive) and fs.upper() == "FAT32" and label == VOLUME_LABEL """
res = {"ok": ok, "drive": drive, import ctypes
"message": "Formatted FAT32 (MBR)." if ok else f"Card state after formatting: {fs or 'no file system'} " from ctypes import wintypes
f"'{label}' - formatting probably failed."}
log.append(res.get("message", "")) k32 = ctypes.WinDLL("kernel32", use_last_error=True)
return Outcome(bool(res.get("ok")), res.get("message", ""), res.get("drive", ""), log) k32.CreateFileW.restype = wintypes.HANDLE
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
def _eject_windows(mount: str) -> bool: k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
"""Flush, lock, dismount and eject the card - what 'Safely Remove' does. wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
Not Shell.Application InvokeVerb('Eject'): it fails silently because the FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020
verb is named 'E&ject' (and is localised on non-English Windows). IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808
""" handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
import ctypes if handle in (None, wintypes.HANDLE(-1).value):
from ctypes import wintypes return False
ret = wintypes.DWORD()
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateFileW.restype = wintypes.HANDLE def ioctl(code: int, buf=None, size: int = 0) -> bool:
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID, return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None))
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD, try:
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID] k32.FlushFileBuffers(handle)
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3 for _ in range(10): # Explorer/indexer may hold it briefly
FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x00090020 if ioctl(FSCTL_LOCK_VOLUME):
IOCTL_STORAGE_MEDIA_REMOVAL, IOCTL_STORAGE_EJECT_MEDIA = 0x002D4804, 0x002D4808 break
handle = k32.CreateFileW(f"\\\\.\\{mount[0]}:", GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None) time.sleep(0.5)
if handle in (None, wintypes.HANDLE(-1).value): else:
return False return False
ret = wintypes.DWORD() if not ioctl(FSCTL_DISMOUNT_VOLUME):
return False
def ioctl(code: int, buf=None, size: int = 0) -> bool: allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE}
return bool(k32.DeviceIoControl(handle, code, buf, size, None, 0, ctypes.byref(ret), None)) ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1)
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices
try: return True
k32.FlushFileBuffers(handle) finally:
for _ in range(10): # Explorer/indexer may hold it briefly k32.CloseHandle(handle)
if ioctl(FSCTL_LOCK_VOLUME):
break
time.sleep(0.5) # ------------------------------------------------------------------- macOS
else:
return False def _format_macos(disk: Disk, log: list[str]) -> Outcome:
if not ioctl(FSCTL_DISMOUNT_VOLUME): log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}")
return False proc = subprocess.run(["diskutil", "eraseDisk", "FAT32", VOLUME_LABEL, "MBRFormat", disk.id],
allow = ctypes.c_bool(False) # PREVENT_MEDIA_REMOVAL{FALSE} capture_output=True, text=True, timeout=600)
ioctl(IOCTL_STORAGE_MEDIA_REMOVAL, ctypes.byref(allow), 1) log += [l for l in proc.stdout.splitlines() if l.strip()]
ioctl(IOCTL_STORAGE_EJECT_MEDIA) # card readers may not support it; dismount suffices if proc.returncode != 0:
return True return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
finally: + " (is the card's write-protect switch on?)", log=log)
k32.CloseHandle(handle) part = f"{disk.id}s1"
for _ in range(20): # wait for the volume to mount
info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"<plist><dict/></plist>")
# ------------------------------------------------------------------- macOS if info.get("MountPoint"):
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log)
def _format_macos(disk: Disk, log: list[str]) -> Outcome: time.sleep(0.5)
log.append(f"diskutil eraseDisk FAT32 {VOLUME_LABEL} MBRFormat {disk.id}") return Outcome(False, "Formatted, but the card did not mount.", log=log)
proc = subprocess.run(["diskutil", "eraseDisk", "FAT32", VOLUME_LABEL, "MBRFormat", disk.id],
capture_output=True, text=True, timeout=600)
log += [l for l in proc.stdout.splitlines() if l.strip()] def _eject_macos(disk_id: str) -> bool:
if proc.returncode != 0: return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0
return Outcome(False, "Formatting failed: " + (proc.stderr.strip() or proc.stdout.strip()[-200:])
+ " (is the card's write-protect switch on?)", log=log)
part = f"{disk.id}s1" # ------------------------------------------------------------------ common
for _ in range(20): # wait for the volume to mount
info = plistlib.loads(subprocess.run(["diskutil", "info", "-plist", part], capture_output=True).stdout or b"<plist><dict/></plist>") def write_env(mount: str, env: EnvConfig) -> Outcome:
if info.get("MountPoint"): """Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back."""
return Outcome(True, "Formatted FAT32 (MBR).", info["MountPoint"], log) problems = [i.text for i in env.problems() if i.level == "error"]
time.sleep(0.5) if problems:
return Outcome(False, "Formatted, but the card did not mount.", log=log) return Outcome(False, "Settings are not valid: " + " ".join(problems))
root = Path(mount)
if not root.is_dir():
def _eject_macos(disk_id: str) -> bool: return Outcome(False, f"Card not found at {mount}.")
return subprocess.run(["diskutil", "eject", disk_id], capture_output=True, timeout=60).returncode == 0 old = find_case_insensitive(root, ENV_NAME)
if old is not None and old.name != ENV_NAME:
old.unlink() # replace env.txt / Env.txt variants
# ------------------------------------------------------------------ common target = root / ENV_NAME
data = env.render()
def write_env(mount: str, env: EnvConfig) -> Outcome: with open(target, "wb") as fh:
"""Write ENV.TXT (8.3 name, LF, no BOM), keep other files (buffered data), read back.""" fh.write(data)
problems = [i.text for i in env.problems() if i.level == "error"] fh.flush()
if problems: os.fsync(fh.fileno())
return Outcome(False, "Settings are not valid: " + " ".join(problems)) if target.read_bytes() != data:
root = Path(mount) return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.")
if not root.is_dir(): check = parse_env(target.read_bytes())
return Outcome(False, f"Card not found at {mount}.") if not check.ok:
old = find_case_insensitive(root, ENV_NAME) return Outcome(False, "ENV.TXT was written but does not validate: "
if old is not None and old.name != ENV_NAME: + " ".join(i.text for i in check.issues if i.level == "error"))
old.unlink() # replace env.txt / Env.txt variants return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount)
target = root / ENV_NAME
data = env.render()
with open(target, "wb") as fh: def format_and_write(disk: Disk, env: EnvConfig, eject_card: bool = True) -> Outcome:
fh.write(data) problems = [i.text for i in env.problems() if i.level == "error"]
fh.flush() if problems:
os.fsync(fh.fileno()) return Outcome(False, "Settings are not valid: " + " ".join(problems))
if target.read_bytes() != data: log: list[str] = []
return Outcome(False, "ENV.TXT read-back does not match - the card may be faulty.") if sys.platform == "win32":
check = parse_env(target.read_bytes()) fmt = _format_windows(disk, log)
if not check.ok: elif sys.platform == "darwin":
return Outcome(False, "ENV.TXT was written but does not validate: " fmt = _format_macos(disk, log)
+ " ".join(i.text for i in check.issues if i.level == "error")) else:
return Outcome(True, f"ENV.TXT written and verified on {mount}.", mount) return Outcome(False, "SD provisioning is supported on Windows and macOS only.")
if not fmt.ok:
return fmt
def format_and_write(disk: Disk, env: EnvConfig, eject_card: bool = True) -> Outcome: wr = write_env(fmt.mount, env)
problems = [i.text for i in env.problems() if i.level == "error"] log.append(wr.message)
if problems: if not wr.ok:
return Outcome(False, "Settings are not valid: " + " ".join(problems)) return Outcome(False, wr.message, fmt.mount, log)
log: list[str] = [] if eject_card:
if sys.platform == "win32": log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else
fmt = _format_windows(disk, log) "Could not eject the card automatically - use 'Safely remove' before pulling it out.")
elif sys.platform == "darwin": return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log)
fmt = _format_macos(disk, log)
else:
return Outcome(False, "SD provisioning is supported on Windows and macOS only.") def eject(mount: str, disk: Disk | None = None) -> bool:
if not fmt.ok: """True if the card was dismounted and can be pulled out safely."""
return fmt if sys.platform == "win32":
wr = write_env(fmt.mount, env) return _eject_windows(mount)
log.append(wr.message) if sys.platform == "darwin" and disk is not None:
if not wr.ok: return _eject_macos(disk.id)
return Outcome(False, wr.message, fmt.mount, log) return False
if eject_card:
log.append("Card ejected - you can remove it." if eject(fmt.mount, disk) else
"Could not eject the card automatically - use 'Safely remove' before pulling it out.") def check_card(mount: str, filesystem: str = "") -> CardReport:
return Outcome(True, "Card ready: formatted FAT32 and ENV.TXT verified.", fmt.mount, log) """What the box would find on this card (values masked in the UI)."""
root = Path(mount)
problems: list[str] = []
def eject(mount: str, disk: Disk | None = None) -> bool: # Windows says "FAT32"; macOS diskutil says "DOS_FAT_32" / "Windows_FAT_32" / "MS-DOS FAT32".
"""True if the card was dismounted and can be pulled out safely.""" fs = filesystem.upper().replace("_", "").replace(" ", "")
if sys.platform == "win32": if fs and "FAT32" not in fs and fs not in ("FAT", "MSDOS"):
return _eject_windows(mount) problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
if sys.platform == "darwin" and disk is not None: env_file = find_case_insensitive(root, ENV_NAME)
return _eject_macos(disk.id) env = parse_env(env_file.read_bytes()) if env_file else None
return False if env_file is None:
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
elif len(env_file.name.split(".")[0]) > 8:
def check_card(mount: str, filesystem: str = "") -> CardReport: problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
"""What the box would find on this card (values masked in the UI).""" buf = find_case_insensitive(root, BUFFER_NAME)
root = Path(mount) lines = None
problems: list[str] = [] if buf is not None:
fs = filesystem.upper() with open(buf, "rb") as fh:
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"): lines = sum(1 for ln in fh if ln.strip())
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.") return CardReport(mount, filesystem, env, lines, problems)
env_file = find_case_insensitive(root, ENV_NAME)
env = parse_env(env_file.read_bytes()) if env_file else None
if env_file is None:
problems.append("No ENV.TXT on the card - the box stops at start-up (white LED).")
elif len(env_file.name.split(".")[0]) > 8:
problems.append("ENV.TXT has a long name variant; use exactly ENV.TXT.")
buf = find_case_insensitive(root, BUFFER_NAME)
lines = None
if buf is not None:
with open(buf, "rb") as fh:
lines = sum(1 for ln in fh if ln.strip())
return CardReport(mount, filesystem, env, lines, problems)
+161
View File
@@ -0,0 +1,161 @@
"""Fast Windows card discovery straight from the storage drivers (ctypes).
Why not Get-Disk / WMI: every fresh PowerShell/WMI query enumerates ALL
disks, which wakes sleeping hard drives - measured 1.6 to 21 s per listing
on a PC with two SATA HDDs, freezing the UI each time. Here each physical
drive is opened with access 0 (no admin needed, no I/O to the medium) and
only its cached device descriptor is read; size and volumes are queried only
for USB/SD/MMC disks. A full listing takes milliseconds.
"""
from __future__ import annotations
import concurrent.futures
import ctypes
import string
from ctypes import wintypes
from .disks import Disk, Volume
IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400
IOCTL_STORAGE_GET_DEVICE_NUMBER = 0x002D1080
IOCTL_DISK_GET_DRIVE_GEOMETRY_EX = 0x000700A0
BUS_TYPES = {1: "SCSI", 2: "ATAPI", 3: "ATA", 4: "1394", 7: "USB", 8: "RAID", 10: "SAS", 11: "SATA",
12: "SD", 13: "MMC", 14: "Virtual", 15: "FileBackedVirtual", 17: "NVMe", 18: "SCM", 19: "UFS"}
SHARE_RW, OPEN_EXISTING = 0x3, 3
DRIVE_REMOVABLE, DRIVE_FIXED = 2, 3
MAX_DRIVES = 32
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
_k32.CreateFileW.restype = wintypes.HANDLE
_k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
_k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
_k32.GetDriveTypeW.argtypes = [wintypes.LPCWSTR]
_k32.GetVolumeInformationW.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD),
ctypes.POINTER(wintypes.DWORD), wintypes.LPWSTR, wintypes.DWORD]
_INVALID = wintypes.HANDLE(-1).value
class _Query(ctypes.Structure): # STORAGE_PROPERTY_QUERY (StorageDeviceProperty, standard)
_fields_ = [("PropertyId", wintypes.DWORD), ("QueryType", wintypes.DWORD), ("Extra", ctypes.c_ubyte * 4)]
class _DevNumber(ctypes.Structure): # STORAGE_DEVICE_NUMBER
_fields_ = [("DeviceType", wintypes.DWORD), ("DeviceNumber", wintypes.DWORD),
("PartitionNumber", wintypes.DWORD)]
def _open(path: str):
h = _k32.CreateFileW(path, 0, SHARE_RW, None, OPEN_EXISTING, 0, None)
return None if h in (None, _INVALID) else h
def _ioctl(h, code: int, inbuf=None, insize: int = 0, outsize: int = 1024) -> bytes | None:
out = ctypes.create_string_buffer(outsize)
got = wintypes.DWORD()
ok = _k32.DeviceIoControl(h, code, inbuf, insize, out, outsize, ctypes.byref(got), None)
return out.raw[:got.value] if ok else None
def _descriptor(h) -> tuple[str, str] | None:
"""(bus, "vendor product") from STORAGE_DEVICE_DESCRIPTOR - served from the driver's cache."""
q = _Query(0, 0)
raw = _ioctl(h, IOCTL_STORAGE_QUERY_PROPERTY, ctypes.byref(q), ctypes.sizeof(q))
if not raw or len(raw) < 32:
return None
vendor_off, product_off = int.from_bytes(raw[12:16], "little"), int.from_bytes(raw[16:20], "little")
bus = int.from_bytes(raw[28:32], "little")
def text(off: int) -> str:
if not off or off >= len(raw):
return ""
return raw[off:].split(b"\0", 1)[0].decode("ascii", "replace").strip()
return BUS_TYPES.get(bus, f"bus{bus}"), " ".join(t for t in (text(vendor_off), text(product_off)) if t) or "Disk"
def _size(h) -> int:
raw = _ioctl(h, IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, outsize=256) # fails when no card is inserted
return int.from_bytes(raw[24:32], "little") if raw and len(raw) >= 32 else 0
def _volumes_by_disk() -> dict[int, list[Volume]]:
found: dict[int, list[Volume]] = {}
mask = _k32.GetLogicalDrives()
for i, letter in enumerate(string.ascii_uppercase):
if not mask & (1 << i) or _k32.GetDriveTypeW(f"{letter}:\\") not in (DRIVE_REMOVABLE, DRIVE_FIXED):
continue
h = _open(f"\\\\.\\{letter}:")
if h is None:
continue
try:
raw = _ioctl(h, IOCTL_STORAGE_GET_DEVICE_NUMBER, outsize=ctypes.sizeof(_DevNumber))
finally:
_k32.CloseHandle(h)
if raw and len(raw) >= 8:
num = int.from_bytes(raw[4:8], "little")
found.setdefault(num, []).append(Volume(f"{letter}:\\", "", ""))
return found
def _fill_volume_info(v: Volume) -> None:
name, fs = ctypes.create_unicode_buffer(64), ctypes.create_unicode_buffer(32)
serial, maxlen, flags = wintypes.DWORD(), wintypes.DWORD(), wintypes.DWORD()
if _k32.GetVolumeInformationW(v.mount, name, 64, ctypes.byref(serial), ctypes.byref(maxlen),
ctypes.byref(flags), fs, 32):
v.filesystem, v.label = fs.value, name.value
MEDIA_TIMEOUT_S = 1.5
# Slot queries still running from an earlier listing (an empty card-reader
# slot can block ~13 s on its first media query). Never start a second one.
_pending: dict[int, concurrent.futures.Future] = {}
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="sd-media")
def _media_size(n: int) -> int:
h = _open(f"\\\\.\\PhysicalDrive{n}")
if h is None:
return 0
try:
return _size(h)
finally:
_k32.CloseHandle(h)
def list_disks(buses: set[str]) -> list[Disk]:
"""Disks on the given buses (e.g. {"USB", "SD", "MMC"}), with size and volumes.
Media queries run in parallel with a short timeout: a slot that does not
answer in time is reported as "no card" instead of freezing the caller.
"""
candidates: list[tuple[int, tuple[str, str]]] = []
for n in range(MAX_DRIVES):
h = _open(f"\\\\.\\PhysicalDrive{n}")
if h is None:
continue
try:
desc = _descriptor(h)
finally:
_k32.CloseHandle(h)
if desc is not None and desc[0] in buses: # internal disks: only the cached descriptor is read
candidates.append((n, desc))
for n, _ in candidates:
if n not in _pending or _pending[n].done():
_pending[n] = _pool.submit(_media_size, n)
concurrent.futures.wait([_pending[n] for n, _ in candidates], timeout=MEDIA_TIMEOUT_S)
volumes: dict[int, list[Volume]] | None = None
disks = []
for n, (bus, name) in candidates:
fut = _pending[n]
size = fut.result() if fut.done() and fut.exception() is None else 0
if size and volumes is None:
volumes = _volumes_by_disk()
vols = (volumes or {}).get(n, []) if size else []
for v in vols:
_fill_volume_info(v)
disks.append(Disk(str(n), name, size, bus, vols, has_media=size > 0))
return disks
+192
View File
@@ -0,0 +1,192 @@
"""Elevated Windows SD-card formatter (runs as: python -m kota_tech.sd.win_format ...).
Replaces Clear-Disk / New-Partition / Format-Volume: every Storage cmdlet
goes through the storage provider, which stalls ~13 s on an empty card
reader slot, several times per format. This helper touches only the chosen
disk through its own driver:
1. guard: bus is USB/SD/MMC, size is exactly the one the technician
confirmed, and the disk does not hold the Windows drive;
2. lock + dismount the card's volumes;
3. raw writes: zero old MBR/GPT, FAT32 structures (kota_tech.sd.fat32),
then the new MBR last;
4. unlock, let Windows re-read the disk, wait for the FAT32 'KOTA' volume;
5. read-only `chkdsk` of the new volume.
The result (with per-step timings) goes to a JSON file for the non-elevated
tool, which then writes ENV.TXT through the normal file API.
"""
from __future__ import annotations
import argparse
import ctypes
import json
import os
import random
import subprocess
import sys
import time
from ctypes import wintypes
from . import fat32, win_disks
GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 0x3, 3
FSCTL_LOCK_VOLUME, FSCTL_UNLOCK_VOLUME, FSCTL_DISMOUNT_VOLUME = 0x00090018, 0x0009001C, 0x00090020
IOCTL_DISK_UPDATE_PROPERTIES = 0x00070140
CARD_BUSES = {"USB", "SD", "MMC"}
CHUNK = 1024 * 1024
k32 = win_disks._k32
k32.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
k32.SetFilePointerEx.argtypes = [wintypes.HANDLE, ctypes.c_longlong, ctypes.POINTER(ctypes.c_longlong), wintypes.DWORD]
k32.VirtualAlloc.restype = wintypes.LPVOID
k32.VirtualAlloc.argtypes = [wintypes.LPVOID, ctypes.c_size_t, wintypes.DWORD, wintypes.DWORD]
class FormatError(RuntimeError):
pass
def _open_rw(path: str):
h = k32.CreateFileW(path, GENERIC_RW, SHARE_RW, None, OPEN_EXISTING, 0, None)
if h in (None, win_disks._INVALID):
raise FormatError(f"cannot open {path} (error {ctypes.get_last_error()})")
return h
def _ioctl(h, code: int) -> bool:
got = wintypes.DWORD()
return bool(k32.DeviceIoControl(h, code, None, 0, None, 0, ctypes.byref(got), None))
class RawDisk:
"""Sector-aligned writes through a page-aligned buffer (device alignment rules)."""
def __init__(self, number: int, bps: int):
self.h = _open_rw(rf"\\.\PhysicalDrive{number}")
self.bps = bps
self.buf = k32.VirtualAlloc(None, CHUNK, 0x3000, 0x04) # MEM_COMMIT|RESERVE, RW
def write(self, offset: int, data: bytes) -> None:
if offset % self.bps or len(data) % self.bps:
raise FormatError("unaligned write")
pos = 0
while pos < len(data):
n = min(CHUNK, len(data) - pos)
ctypes.memmove(self.buf, data[pos:pos + n], n)
self._raw(offset + pos, n)
pos += n
def zero(self, offset: int, length: int) -> None:
ctypes.memset(self.buf, 0, CHUNK)
pos = 0
while pos < length:
n = min(CHUNK, length - pos)
self._raw(offset + pos, n)
pos += n
def _raw(self, offset: int, n: int) -> None:
if not k32.SetFilePointerEx(self.h, offset, None, 0):
raise FormatError(f"seek failed (error {ctypes.get_last_error()})")
done = wintypes.DWORD()
if not k32.WriteFile(self.h, self.buf, n, ctypes.byref(done), None) or done.value != n:
raise FormatError(f"write at {offset} failed (error {ctypes.get_last_error()}) - write-protected card?")
def close(self) -> None:
k32.FlushFileBuffers(self.h)
_ioctl(self.h, IOCTL_DISK_UPDATE_PROPERTIES) # re-read the new partition table
k32.CloseHandle(self.h)
def format_card(number: int, expected_size: int, label: str, timings: dict) -> str:
t0 = time.perf_counter()
disk = next((d for d in win_disks.list_disks(CARD_BUSES) if d.id == str(number)), None)
if disk is None:
raise FormatError("Refusing: the disk is not a USB/SD/MMC card reader.")
if disk.size != expected_size:
raise FormatError("Refusing: the disk changed since it was selected (card swapped?).")
system = os.environ.get("SystemDrive", "C:").upper().rstrip("\\") + "\\"
if any(v.mount.upper() == system for v in disk.volumes):
raise FormatError("Refusing: this disk holds the Windows drive.")
timings["guard"] = time.perf_counter() - t0
t = time.perf_counter()
locked = []
for v in disk.volumes:
h = _open_rw(rf"\\.\{v.mount[0]}:")
for _ in range(20):
if _ioctl(h, FSCTL_LOCK_VOLUME):
break
time.sleep(0.25)
else:
k32.CloseHandle(h)
raise FormatError(f"{v.mount} is in use by another program - close it and try again.")
_ioctl(h, FSCTL_DISMOUNT_VOLUME)
locked.append(h)
timings["lock_volumes"] = time.perf_counter() - t
t = time.perf_counter()
lay = fat32.plan(disk.size)
zeros, writes = fat32.build(lay, label, random.getrandbits(32), random.getrandbits(32), disk.size)
raw = RawDisk(number, lay.bytes_per_sector)
try:
for off, n in zeros:
raw.zero(off, n)
for off, data in writes: # MBR is last: the partition appears complete
raw.write(off, data)
finally:
for h in locked:
_ioctl(h, FSCTL_UNLOCK_VOLUME)
k32.CloseHandle(h)
raw.close()
timings["write"] = time.perf_counter() - t
t = time.perf_counter()
drive = ""
for _ in range(80): # Windows mounts the new volume by itself
d = next((x for x in win_disks.list_disks(CARD_BUSES) if x.id == str(number)), None)
vol = next((v for v in (d.volumes if d else []) if v.filesystem.upper() == "FAT32"), None)
if vol and vol.label.upper() == label.upper():
drive = vol.mount
break
time.sleep(0.25)
timings["mount"] = time.perf_counter() - t
if not drive:
raise FormatError("Formatted, but Windows did not mount the card - remove and re-insert it.")
t = time.perf_counter()
chk = subprocess.run(["chkdsk", drive.rstrip("\\")], capture_output=True, text=True, timeout=120,
creationflags=subprocess.CREATE_NO_WINDOW)
timings["chkdsk"] = time.perf_counter() - t
if chk.returncode != 0:
tail = " ".join(chk.stdout.split()[-30:])
raise FormatError(f"chkdsk reported problems on the new file system: {tail}")
return drive
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--disk", type=int, required=True)
ap.add_argument("--size", type=int, required=True)
ap.add_argument("--label", default="KOTA")
ap.add_argument("--result", required=True)
a = ap.parse_args(argv)
timings: dict[str, float] = {}
result = {"ok": False, "message": "", "drive": "", "timings": timings}
try:
result["drive"] = format_card(a.disk, a.size, a.label, timings)
result["ok"] = True
result["message"] = "Formatted FAT32 (MBR), checked with chkdsk."
except FormatError as exc:
result["message"] = str(exc)
except Exception as exc: # report everything to the tool
result["message"] = f"Unexpected error: {exc!r}"
with open(a.result, "w", encoding="utf-8") as fh:
json.dump(result, fh)
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
+155 -101
View File
@@ -1,101 +1,155 @@
"""Hand-off of a diagnosis to the KOTA service system (Google Apps Script). """Hand-off of a diagnosis to the KOTA service system (Google Apps Script).
The tool opens the box's service page in the technician's browser: The tool opens the box's service page in the technician's browser:
<base><MAC>#kota-report=<base64url(JSON)> <base><MAC>#kota-report=<base64url(JSON)>
The service page (v1.1.0+) reads the fragment and pre-fills status and note; The service page (v1.1.0+) reads the fragment and pre-fills status and note;
the technician checks it, adds the location and saves - so the report is the technician checks it, adds the location and saves - so the report is
authored by their own Google login and the tool holds no credentials. authored by their own Google login and the tool holds no credentials.
The fragment is never sent to any server (redirect host or Apps Script). The fragment is never sent to any server (redirect host or Apps Script).
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
import json import json
import re import re
from .. import __version__ from .. import __version__
from ..diag.engine import Report, Severity from ..diag.engine import Report, Severity
DEFAULT_BASE = "https://service.kota-app.com/" DEFAULT_BASE = "https://service.kota-app.com/"
NOTE_LIMIT = 4000 # the service form accepts 5000; leave room for the technician NOTE_LIMIT = 4800 # the service form and Code.gs accept 5000; leave room for the technician
# Status names exactly as in the service system (Code.gs STATUSES). # Status names exactly as in the service system (Code.gs STATUSES).
OK = "Działa poprawnie" OK = "Działa poprawnie"
CHECK = "Do sprawdzenia" CHECK = "Do sprawdzenia"
IN_SERVICE = "W trakcie serwisu" IN_SERVICE = "W trakcie serwisu"
HARDWARE = "Błąd hardware" HARDWARE = "Błąd hardware"
SOFTWARE = "Błąd software" SOFTWARE = "Błąd software"
POWER = "Problem z zasilaniem" POWER = "Problem z zasilaniem"
CONNECTIVITY = "Problem z łącznością" CONNECTIVITY = "Problem z łącznością"
# Root-finding rule id prefix -> service status (first match wins). # Root-finding rule id prefix -> service status (first match wins).
_STATUS_BY_RULE = [ _STATUS_BY_RULE = [
("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER), ("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER),
("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE), ("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE),
("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE), ("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE),
("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE), ("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE), ("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE),
("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE), ("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE),
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE), ("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY), ("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY), ("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
("rtc.clock_not_running", CHECK), ("reboot.", CHECK), # firmware 1.0.16+ (KOTA: lines, status, error IDs)
] ("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
_MAC = re.compile(r"^[0-9A-F]{12}$") ("kota.low_heap", SOFTWARE),
("kota.i2c_stuck", HARDWARE), ("kota.sensor_missing", HARDWARE), ("kota.rtc_", HARDWARE),
("kota.sd_", HARDWARE), ("status.error", HARDWARE), ("status.rtc", HARDWARE), ("status.sd", HARDWARE),
def normalize_mac(mac: str) -> str: ("kota.wifi_down", CONNECTIVITY), ("kota.weak_wifi", CONNECTIVITY), ("kota.no_upload", CONNECTIVITY),
return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper() ("kota.ota_unregistered", CONNECTIVITY), ("kota.buffer_backlog", CONNECTIVITY), ("status.conn", CONNECTIVITY),
("kota.time_invalid", CHECK),
*((f"eid.{m}", HARDWARE) for m in ("SD", "ENV", "RTC", "BME", "SEN", "AS", "MIC", "FLK", "SPI")),
def service_status(report: Report) -> str: *((f"eid.{m}", CONNECTIVITY) for m in ("INF", "WIFI", "OTA", "PROV")),
"""Suggested status; the technician can change it in the form.""" ("eid.", SOFTWARE),
problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING] ("rtc.clock_not_running", CHECK), ("reboot.", CHECK),
if not problems: ]
return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK _MAC = re.compile(r"^[0-9A-F]{12}$")
rid = problems[0].finding.rule_id
for prefix, status in _STATUS_BY_RULE:
if rid.startswith(prefix): def normalize_mac(mac: str) -> str:
return status return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper()
return CHECK
def service_status(report: Report) -> str:
def service_note(report: Report, limit: int = NOTE_LIMIT) -> str: """Suggested status; the technician can change it in the form."""
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING]
f"Firmware: {report.firmware.describe()}", if not problems:
f"Expected LED: {report.led.capitalize()}"] return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK
reached = [label for _, label, t in report.milestones if t is not None] rid = problems[0].finding.rule_id
missing = [label for _, label, t in report.milestones if t is None] for prefix, status in _STATUS_BY_RULE:
if reached: if rid.startswith(prefix):
lines.append("OK: " + ", ".join(reached)) return status
if missing: return CHECK
lines.append("Not reached: " + ", ".join(missing))
for rc in report.roots:
f = rc.finding BOX_MARK = {"ok": "✔", "warn": "!", "bad": "✖", "info": "i"} # same marks as the Diagnosis panel
lines.append(f"- [{f.severity.label}] {f.title}") SD_WIFI = {True: "reads Wi-Fi settings from the SD card",
for c in rc.consequences: False: "does not read Wi-Fi settings from the SD card",
lines.append(f" -> {c.title}") None: "Wi-Fi-from-SD support unknown"}
if report.technician:
lines.append("Technician checks:")
lines += [f" * {t}" for t in report.technician[-12:]] def service_note(report: Report, limit: int = NOTE_LIMIT) -> str:
text = "\n".join(lines) """The Diagnosis panel as text: verdict, LED, firmware, box self-report,
return text if len(text) <= limit else text[:limit - 20].rstrip() + "\n...(shortened)" start-up progress with times, findings with details and when they were seen.
Falls back to shorter finding details when the full text does not fit the
def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str: service form (consequences' explanations first, then all), then truncates.
mac = normalize_mac(mac) """
if not _MAC.match(mac): for detail in ("all", "roots", "none"):
raise ValueError(f"not a box MAC address: {mac!r}") text = _note(report, detail)
payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report), if len(text) <= limit:
"tool": f"v{__version__}"} return text
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") return text[:limit - 20].rstrip() + "\n...(shortened)"
frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
base = base if base.endswith("/") else base + "/"
return f"{base}{mac}#kota-report={frag}" def _note(report: Report, detail: str) -> str:
fw = report.firmware
source = " (reported by the box)" if report.led_exact else ""
def decode_fragment(url: str) -> dict: lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", "",
"""Inverse of report_url (used by tests and for troubleshooting).""" f"Status LED should be: {report.led_text()}{source}"]
frag = url.split("#kota-report=", 1)[1] if report.led_reason:
return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8")) lines.append(f" from: {report.led_reason}")
lines += ["", f"Firmware: {fw.describe()}", f" {SD_WIFI[fw.supports_sd_wifi]}"]
if report.box:
lines += ["", "Box self-report (KOTA lines):"]
lines += [f" {BOX_MARK.get(st, '-')} {topic}: {text}" for topic, text, st in report.box]
lines += ["", "Start-up progress (current boot):"]
lines += [f" ✔ {label} ({t / 1000:.1f} s)" if t is not None else f" ○ {label}"
for _, label, t in report.milestones]
lines += ["", "Findings - root causes first:" if report.roots else "Findings: no problems detected in the log"]
for rc in report.roots:
lines += _finding(rc.finding, "", detail in ("all", "roots"))
for c in rc.consequences:
lines += _finding(c, " ↳ ", detail == "all")
for title, items in (("Seen in earlier boots only", report.earlier),
("Resolved during this session", report.resolved)):
if items:
lines += ["", f"{title} ({len(items)}):"]
lines += [f" - [{f.severity.label}] {f.title} - {f.seen_text()}" for f in items]
if report.technician:
lines += ["", "Technician checks:"]
lines += [f" * {t}" for t in report.technician[-12:]]
return "\n".join(lines)
def _finding(f, prefix: str, detailed: bool) -> list[str]:
ind = " " * len(prefix) + " "
out = [f"{prefix}[{f.severity.label}] {f.title}"]
if detailed:
out.append(f"{ind}What it means: {f.meaning}")
if f.actions:
out.append(f"{ind}What to do:")
out += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)]
out += [f"{ind}Error {eid}" + (f": {a}" if a else "") for eid, a in f.error_ids.items()]
out.append(f"{ind}{f.seen_text()}")
if f.evidence:
out.append(f"{ind}Log: {f.evidence[-1]}")
return out
def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str:
mac = normalize_mac(mac)
if not _MAC.match(mac):
raise ValueError(f"not a box MAC address: {mac!r}")
payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report),
"tool": f"v{__version__}"}
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
base = base if base.endswith("/") else base + "/"
return f"{base}{mac}#kota-report={frag}"
def decode_fragment(url: str) -> dict:
"""Inverse of report_url (used by tests and for troubleshooting)."""
frag = url.split("#kota-report=", 1)[1]
return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8"))
+87 -85
View File
@@ -1,85 +1,87 @@
"""Quick-start cheat sheet shown on launch. """Quick-start cheat sheet shown on launch.
Targets the DEPLOYED firmware: new_system.bin, app "kota-sensorboard" LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md,
1.0, built Jun 29 2026 11:03:32, ELF SHA256 45fa9d5b9... (verified on a status.cpp Status::pattern_for): colour AND pattern name the fault. Older
live board). That build has no Wi-Fi setup portal / SSID-on-SD support; boxes (v1.0, built Jun 29 2026) show the same colours, always solid.
those exist only in the newer, unbuilt source. The status LED is always """
SOLID; the separate small LED toggles every second while alive.
""" # (swatch colour, LED name, pattern -> meaning, first action)
LED_TABLE = [
# (swatch colour, LED name, meaning, first action) ("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."),
LED_TABLE = [ ("#1fc8d8", "Cyan",
("#2e9e44", "Green", "Everything OK.", "Nothing to do."), "breathing: not paired in Matter yet<br>fast blink: a phone is connected for pairing.",
("#8e44ad", "Purple", "Pair the box in the Matter app (QR sticker or the QR code in the tool)."),
"No Internet / InfluxDB or OTA server unreachable.", ("#f4f4f4", "White",
"Check the box is commissioned in the Matter app (sticker QR code) and the " "fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>"
"site Wi-Fi has Internet. Board reboots itself after ~2.5 min of purple."), "breathing: Wi-Fi setup portal.",
("#d64541", "Red", "SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."),
"Sensor or hardware fault (BME688, SEN66, AS7341, microphone, flicker) " ("#d64541", "Red",
"or failed self-check after update.", "1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed<br>"
"Connect serial - the log names the failing sensor. Check sensor cables."), "solid: other internal error / out of memory.",
("#2f6fd6", "Blue", "Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."),
"Clock (RTC DS1307) failed to start. The RTC is the first I2C device, " ("#2f6fd6", "Blue",
"so a broken I2C cable also shows blue.", "1 pulse: clock (RTC) not responding<br>2 pulses: I2C bus stuck (shorted / broken sensor cable).",
"Check the I2C harness first, then the RTC module / coin cell."), "Check the I2C cable between the boards first, then the RTC module."),
("#e6c229", "Yellow", "Firmware update downloading.", "Do not switch off. Wait."), ("#8e44ad", "Purple",
("#f08c1a", "Orange", "Self-check after an update (~2.5 min).", "solid: no network<br>blinking: server rejected the box (wrong ENV.TXT credentials).",
"Wait; should turn green (or red on failure)."), "Solid: check Wi-Fi / Internet. Blinking: re-provision the SD card with the current ENV.TXT."),
("#f4f4f4", "White", ("#e6c229", "Yellow", "blinking, faster over time: downloading an update.", "Do not switch off. Wait."),
"SD card missing/unreadable or ENV.TXT incomplete - or a factory reset is " ("#f08c1a", "Orange", "solid: self-check after an update (~2.5 min).",
"in progress (Matter button held 5 s).", "Wait; should turn green (or red on failure)."),
"If nobody pressed the Matter button: the SD card is inside the case - " ("#444444", "Off", "Firmware did not start (or crashed very early).",
"unscrew, re-seat or re-provision it."), "Check power switch & USB cable. If the small LED is also dark: re-flash."),
("#444444", "Off", "Firmware did not start (or crashed very early).", ]
"Check power switch & USB cable. If the small LED is also dark: re-flash."),
] HINTS = [
"<b>Before a self-restart</b> the LED flashes the error colour 3 times; <b>after the restart</b> it "
HINTS = [ "shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press "
"<b>Colour keeps changing / LED goes dark every few seconds</b> = reboot loop. " "<i>Reset &amp; capture</i>: the tool reads the restart reason from the box.",
"Connect serial and press <i>Reset &amp; capture</i>: the last lines before the " "<b>Small LED next to the RGB LED</b> (heartbeat): blinks once per second while online, "
"reboot name the cause.", "double-blinks while offline. Dark or steady = firmware hung or not running.",
"<b>Small LED next to the RGB LED</b> blinks once per second while the " "<b>Serial monitor</b>: lines starting with <b>KOTA:</b> are the box's own diagnosis (boot, I2C, SD, "
"firmware runs. Not blinking = firmware hung or not running.", "clock, Matter, microphone, status, LED, and a health line every 60 s). The Diagnosis panel reads them "
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it " "directly; filter the view with <i>KOTA diagnostics + errors</i>. Error lines carry a code like "
"(off = no noise measurements), and the Matter button. Holding the " "<b>[E-SD-03]</b> - the tool shows the recommended action for it.",
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter " "<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
"network and must be commissioned again with the sticker QR code).", "(off = no noise measurements), and the Matter button. Holding the "
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. " "Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
"Normally not needed - the tool resets the board over USB.", "network and must be commissioned again with the sticker QR code).",
"<b>Gathering messages for the first ~2 minutes</b> are normal " "<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
"(the air-quality sensor warms up for 120 s).", "Normally not needed - the tool resets the board over USB.",
] "<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
"by its compile date.",
STEPS = [ ]
"Switch the sensorbox <b>power switch ON</b>.",
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.", STEPS = [
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press " "Switch the sensorbox <b>power switch ON</b>.",
"<b>Connect</b>.", "Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
"Press <b>Reset &amp; capture</b> to record a full boot log from the first line.", "Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
"Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> " "<b>Connect</b>.",
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.", "Press <b>Reset &amp; capture</b> to record a full boot log from the first line.",
] "Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
]
def cheatsheet_html() -> str:
rows = "".join(
f"<tr><td style='padding:6px'><span style='background:{c};" def cheatsheet_html() -> str:
f"border:1px solid #888'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>&nbsp;<b>{name}</b></td>" rows = "".join(
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>" f"<tr><td style='padding:6px'><span style='background:{c};"
for c, name, meaning, action in LED_TABLE f"border:1px solid #888'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>&nbsp;<b>{name}</b></td>"
) f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
steps = "".join(f"<li>{s}</li>" for s in STEPS) for c, name, meaning, action in LED_TABLE
hints = "".join(f"<li>{h}</li>" for h in HINTS) )
return f""" steps = "".join(f"<li>{s}</li>" for s in STEPS)
<h2>Sensorbox quick start</h2> hints = "".join(f"<li>{h}</li>" for h in HINTS)
<ol>{steps}</ol> return f"""
<h3>RGB status LED (always solid colour)</h3> <h2>Sensorbox quick start</h2>
<table border='1' cellspacing='0' cellpadding='0' width='100%'> <ol>{steps}</ol>
<tr><th align='left' style='padding:6px'>LED</th> <h3>RGB status LED - colour and pattern</h3>
<th align='left' style='padding:6px'>Most likely meaning</th> <table border='1' cellspacing='0' cellpadding='0' width='100%'>
<th align='left' style='padding:6px'>First action</th></tr> <tr><th align='left' style='padding:6px'>LED</th>
{rows} <th align='left' style='padding:6px'>Pattern: meaning</th>
</table> <th align='left' style='padding:6px'>First action</th></tr>
<h3>Good to know</h3> {rows}
<ul>{hints}</ul> </table>
""" <h3>Good to know</h3>
<ul>{hints}</ul>
"""
+31 -9
View File
@@ -1,8 +1,9 @@
"""Diagnostics side panel: renders a diag.engine.Report. """Diagnostics side panel: renders a diag.engine.Report.
Layout: verdict banner, expected LED, firmware, start-up milestones, a Layout: verdict banner, expected LED (colour + pattern + meaning when the
findings tree (root causes; their consequences as children; titles only) box reports it), firmware, the box's own self-report (KOTA lines), start-up
and a wrapped detail pane for the selected finding. milestones, a findings tree (root causes; their consequences as children;
titles only) and a wrapped detail pane for the selected finding.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,6 +16,7 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QPushButton,
QSizePolicy, QSizePolicy,
QSplitter, QSplitter,
QTextBrowser, QTextBrowser,
@@ -40,6 +42,7 @@ BANNER = {
None: ("#d8f0dd", "#1b5e2a"), None: ("#d8f0dd", "#1b5e2a"),
} }
_KEY = Qt.ItemDataRole.UserRole _KEY = Qt.ItemDataRole.UserRole
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
def _heading(text: str) -> QLabel: def _heading(text: str) -> QLabel:
@@ -76,6 +79,11 @@ class DiagnosticsPanel(QWidget):
self.firmware = QLabel(wordWrap=True) self.firmware = QLabel(wordWrap=True)
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.box_title = _heading("Box self-report (KOTA lines)")
self.box = QLabel(wordWrap=True)
self.box.setTextFormat(Qt.TextFormat.RichText)
self.box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.milestones = QListWidget() self.milestones = QListWidget()
self.milestones.setFrameShape(QFrame.Shape.NoFrame) self.milestones.setFrameShape(QFrame.Shape.NoFrame)
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection) self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
@@ -99,6 +107,11 @@ class DiagnosticsPanel(QWidget):
lay.addLayout(led_row) lay.addLayout(led_row)
lay.addWidget(_heading("Firmware")) lay.addWidget(_heading("Firmware"))
lay.addWidget(self.firmware) lay.addWidget(self.firmware)
self.qr_btn = QPushButton("Matter pairing QR code...")
self.qr_btn.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("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"))
@@ -124,8 +137,11 @@ class DiagnosticsPanel(QWidget):
self.led_swatch.setStyleSheet( self.led_swatch.setStyleSheet(
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}") f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>" ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else ""
meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning else ""
source = " <small style='color:#1b7a33'>(reported by the box)</small>" if r.led_exact else ""
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else "" reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
self.led_text.setText(f"<b>{name}</b>{ask}{reason}") self.led_text.setText(f"<b>{name}{pattern}</b>{meaning}{source}{ask}{reason}")
fw = r.firmware fw = r.firmware
sd = {True: "reads Wi-Fi settings from the SD card", sd = {True: "reads Wi-Fi settings from the SD card",
@@ -133,6 +149,13 @@ class DiagnosticsPanel(QWidget):
None: "Wi-Fi-from-SD support unknown"} None: "Wi-Fi-from-SD support unknown"}
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>") self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
self.box_title.setVisible(bool(r.box))
self.box.setVisible(bool(r.box))
self.box.setText("<table cellspacing='0' cellpadding='1'>" + "".join(
f"<tr><td style='color:{BOX_STATE[st][0]}'><b>{BOX_STATE[st][1]}</b>&nbsp;</td>"
f"<td><b>{_esc(topic)}</b>&nbsp;</td><td>{_esc(text)}</td></tr>"
for topic, text, st in r.box) + "</table>")
self.milestones.clear() self.milestones.clear()
for _, label, t in r.milestones: for _, label, t in r.milestones:
done = t is not None done = t is not None
@@ -214,16 +237,15 @@ class DiagnosticsPanel(QWidget):
def _detail_html(f: Finding) -> str: def _detail_html(f: Finding) -> str:
colour = SEV_COLOR.get(f.severity, "#333") colour = SEV_COLOR.get(f.severity, "#333")
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions) actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
seen = f"Seen {f.count}x" seen = _esc(f.seen_text())
if f.first_uptime_ms is not None:
seen += f", first at {f.first_uptime_ms / 1000:.1f} s after start"
if f.resolved and f.resolved_by:
seen += f" - resolved ({_esc(f.resolved_by)})"
evidence = "<br>".join(_esc(e) for e in f.evidence) evidence = "<br>".join(_esc(e) for e in f.evidence)
codes = "".join(f"<li><b>{_esc(eid)}</b>" + (f" - {_esc(a)}" if a else "") + "</li>"
for eid, a in f.error_ids.items())
return ( return (
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>" f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>" f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "") + (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
+ (f"<p><b>Firmware error codes</b> (error_ids.csv):</p><ul>{codes}</ul>" if codes else "")
+ f"<p style='color:#666'>{seen}</p>" + f"<p style='color:#666'>{seen}</p>"
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>" + (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
if evidence else "") if evidence else "")
+364 -391
View File
@@ -1,391 +1,364 @@
"""Flash firmware tab: factory image or imported developer builds; App-only by default.""" """Flash firmware tab: only the .bin files in the tool's firmware/ folder,
chosen by their compile date; App-only by default."""
from __future__ import annotations
from __future__ import annotations
import datetime as dt
import tempfile import datetime as dt
from pathlib import Path import tempfile
from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtWidgets import ( from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
QButtonGroup, from PySide6.QtGui import QDesktopServices
QCheckBox, from PySide6.QtWidgets import (
QFileDialog, QButtonGroup,
QComboBox, QCheckBox,
QGroupBox, QComboBox,
QHBoxLayout, QGroupBox,
QLabel, QHBoxLayout,
QMessageBox, QLabel,
QPlainTextEdit, QMessageBox,
QProgressBar, QPlainTextEdit,
QPushButton, QProgressBar,
QRadioButton, QPushButton,
QVBoxLayout, QRadioButton,
QWidget, QVBoxLayout,
) QWidget,
)
from ..core.session import SESSIONS_DIR
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, factory_images from ..core.session import SESSIONS_DIR
from ..flash.plan import Mode, build_plan from ..diag.firmware import local_build, register_local_build, same_build
from ..flash.runner import FlashRunner, Result from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_images
from ..serialio.ports import PortInfo, list_serial_ports from ..flash.plan import Mode, build_plan
from ..flash.runner import FlashRunner, Result
BACKUP_DIR = SESSIONS_DIR.parent / "backups" from ..serialio.ports import PortInfo, list_serial_ports
VERIFY_TIMEOUT_MS = 45_000
BACKUP_DIR = SESSIONS_DIR.parent / "backups"
HELP = ( VERIFY_TIMEOUT_MS = 45_000
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, " HELP = (
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again." "<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
) "other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
)
class _Bridge(QObject):
step = Signal(int, str)
progress = Signal(int) class _Bridge(QObject):
output = Signal(str) step = Signal(int, str)
done = Signal(object) progress = Signal(int)
output = Signal(str)
done = Signal(object)
class FlashTab(QWidget):
def __init__(self, serial_tab, parent: QWidget | None = None):
super().__init__(parent) class FlashTab(QWidget):
self.serial_tab = serial_tab def __init__(self, serial_tab, parent: QWidget | None = None):
self._images: list[FirmwareImage] = [] super().__init__(parent)
self._ports: list[PortInfo] = [] # before any signal can call _update() self.serial_tab = serial_tab
self._runner: FlashRunner | None = None self._images: list[FirmwareImage] = []
self._port: PortInfo | None = None self._ports: list[PortInfo] = [] # before any signal can call _update()
self._image: FirmwareImage | None = None self._runner: FlashRunner | None = None
self._port: PortInfo | None = None
# 1. box self._image: FirmwareImage | None = None
box = QGroupBox("1. Sensorbox")
self.port_combo = QComboBox(minimumWidth=360) # 1. box
refresh = QPushButton("Refresh") box = QGroupBox("1. Sensorbox")
refresh.clicked.connect(self.refresh_ports) self.port_combo = QComboBox(minimumWidth=360)
self.box_fw = QLabel(wordWrap=True) refresh = QPushButton("Refresh")
bl = QVBoxLayout(box) refresh.clicked.connect(self.refresh_ports)
row = QHBoxLayout() self.box_fw = QLabel(wordWrap=True)
row.addWidget(self.port_combo) bl = QVBoxLayout(box)
row.addWidget(refresh) row = QHBoxLayout()
row.addStretch(1) row.addWidget(self.port_combo)
bl.addLayout(row) row.addWidget(refresh)
bl.addWidget(self.box_fw) row.addStretch(1)
bl.addLayout(row)
# 2. firmware bl.addWidget(self.box_fw)
fwbox = QGroupBox("2. Firmware")
self.image_combo = QComboBox(minimumWidth=360) # 2. firmware - only files in the tool's firmware/ folder
self.import_btn = QPushButton("Import firmware file...") fwbox = QGroupBox("2. Firmware (by compile date)")
self.import_btn.setToolTip("Add a newer build the firmware team sent you (.bin)") self.image_combo = QComboBox(minimumWidth=360)
self.import_btn.clicked.connect(self._import) self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
self.remove_btn = QPushButton("Remove from list") self.reload_btn = QPushButton("Refresh list")
self.remove_btn.clicked.connect(self._remove) self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)")
self.image_info = QLabel(wordWrap=True) self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.folder_btn = QPushButton("Open firmware folder")
fl = QVBoxLayout(fwbox) self.folder_btn.clicked.connect(
fl.addWidget(self.image_combo) lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
brow = QHBoxLayout() self.image_info = QLabel(wordWrap=True)
brow.addWidget(self.import_btn) self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
brow.addWidget(self.remove_btn) fl = QVBoxLayout(fwbox)
brow.addStretch(1) fl.addWidget(self.image_combo)
fl.addLayout(brow) brow = QHBoxLayout()
fl.addWidget(self.image_info) brow.addWidget(self.reload_btn)
self._library = FirmwareLibrary() brow.addWidget(self.folder_btn)
self._reference: FirmwareImage | None = None brow.addStretch(1)
self._problems: list[str] = [] fl.addLayout(brow)
fl.addWidget(self.image_info)
# 3. mode self._problems: list[str] = []
modebox = QGroupBox("3. What to write")
self.rb_app = QRadioButton("App only (recommended for repairs)") # 3. mode
self.rb_full = QRadioButton("Full factory image (new or completely wiped boards)") modebox = QGroupBox("3. What to write")
self.mode_group = QButtonGroup(self) self.rb_app = QRadioButton("App only (recommended for repairs)")
self.mode_group.addButton(self.rb_app) self.rb_full = QRadioButton("Full image (new or completely wiped boards)")
self.mode_group.addButton(self.rb_full) self.mode_group = QButtonGroup(self)
self.rb_app.setChecked(True) self.mode_group.addButton(self.rb_app)
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the " self.mode_group.addButton(self.rb_full)
"settings stored on the box.", wordWrap=True) self.rb_app.setChecked(True)
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA " app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
"registration</b> - the box must be paired again afterwards.", wordWrap=True) "settings stored on the box.", wordWrap=True)
for n in (app_note, full_note): full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
n.setContentsMargins(24, 0, 0, 6) "registration</b> - the box must be paired again afterwards.", wordWrap=True)
n.setStyleSheet("color:#555") for n in (app_note, full_note):
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased") n.setContentsMargins(24, 0, 0, 6)
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)") n.setStyleSheet("color:#555")
ml = QVBoxLayout(modebox) self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup): self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
ml.addWidget(w) ml = QVBoxLayout(modebox)
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
# 4. run ml.addWidget(w)
runbox = QGroupBox("4. Flash")
self.plan_label = QLabel(wordWrap=True) # 4. run
self.start_btn = QPushButton("Start flashing") runbox = QGroupBox("4. Flash")
self.start_btn.setMinimumHeight(36) self.plan_label = QLabel(wordWrap=True)
self.start_btn.clicked.connect(self._start) self.start_btn = QPushButton("Start flashing")
self.step_label = QLabel() self.start_btn.setMinimumHeight(36)
self.bar = QProgressBar() self.start_btn.clicked.connect(self._start)
self.result = QLabel(wordWrap=True) self.step_label = QLabel()
self.result.setMargin(8) self.bar = QProgressBar()
self.result.setTextFormat(Qt.TextFormat.RichText) self.result = QLabel(wordWrap=True)
self.details_btn = QPushButton("Show esptool output") self.result.setMargin(8)
self.details_btn.setCheckable(True) self.result.setTextFormat(Qt.TextFormat.RichText)
self.output = QPlainTextEdit(readOnly=True) self.details_btn = QPushButton("Show esptool output")
self.output.setVisible(False) self.details_btn.setCheckable(True)
self.details_btn.toggled.connect(self.output.setVisible) self.output = QPlainTextEdit(readOnly=True)
rl = QVBoxLayout(runbox) self.output.setVisible(False)
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output): self.details_btn.toggled.connect(self.output.setVisible)
rl.addWidget(w) rl = QVBoxLayout(runbox)
help_lbl = QLabel(HELP, wordWrap=True) for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
help_lbl.setStyleSheet("color:#555") rl.addWidget(w)
rl.addWidget(help_lbl) help_lbl = QLabel(HELP, wordWrap=True)
rl.addStretch(1) # keep the run column packed at the top help_lbl.setStyleSheet("color:#555")
rl.addWidget(help_lbl)
left = QVBoxLayout() rl.addStretch(1) # keep the run column packed at the top
for w in (box, fwbox, modebox):
left.addWidget(w) left = QVBoxLayout()
left.addStretch(1) for w in (box, fwbox, modebox):
lay = QHBoxLayout(self) left.addWidget(w)
lay.addLayout(left, 1) left.addStretch(1)
lay.addWidget(runbox, 1) lay = QHBoxLayout(self)
lay.addLayout(left, 1)
self._bridge = _Bridge() lay.addWidget(runbox, 1)
self._bridge.step.connect(self._on_step)
self._bridge.progress.connect(self.bar.setValue) self._bridge = _Bridge()
self._bridge.output.connect(self.output.appendPlainText) self._bridge.step.connect(self._on_step)
self._bridge.done.connect(self._on_done) self._bridge.progress.connect(self.bar.setValue)
self._verify_timer = QTimer(self, interval=500) self._bridge.output.connect(self.output.appendPlainText)
self._verify_timer.timeout.connect(self._check_verify) self._bridge.done.connect(self._on_done)
self._verify_timer = QTimer(self, interval=500)
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled): self._verify_timer.timeout.connect(self._check_verify)
sig.connect(self._update)
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
self.image_combo.currentIndexChanged.connect(self._update) sig.connect(self._update)
self.port_combo.currentIndexChanged.connect(self._update) self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
self.image_combo.currentIndexChanged.connect(self._update)
self._load_images() self.port_combo.currentIndexChanged.connect(self._update)
self.refresh_ports()
self._show_result("", None) self._load_images()
self.refresh_ports()
# ------------------------------------------------------------ setup self._show_result("", None)
def _load_images(self, select_sha: str | None = None) -> None:
self._problems = [] # ------------------------------------------------------------ setup
try: def _load_images(self, select_sha: str | None = None) -> None:
factory = factory_images() self._images, self._problems = folder_images()
except ImageError as exc: for img in self._images: # lets the Serial monitor show a box build's compile date
factory = [] register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
self._problems.append(f"factory image: {exc}") self.image_combo.blockSignals(True)
self._reference = factory[0] if factory else None self.image_combo.clear()
dev, problems = self._library.images(self._reference) for img in self._images:
self._problems += problems self.image_combo.addItem(img.label())
# Newest developer builds first, then the factory image. if not self._images:
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True) self.image_combo.addItem("No firmware file in the firmware folder")
self._images = dev + factory idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0)
self.image_combo.blockSignals(True) self.image_combo.setCurrentIndex(idx) # default: newest compile date
self.image_combo.clear() self.image_combo.blockSignals(False)
for img in self._images: self._update()
self.image_combo.addItem(img.label())
if not self._images: def _selected_sha(self) -> str | None:
self.image_combo.addItem("No firmware image available - import one") img = self._selected_image()
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), None) return img.sha256 if img else None
if idx is None:
idx = len(dev) if factory else 0 # default: the factory image def refresh_ports(self) -> None:
self.image_combo.setCurrentIndex(idx) current = self.port_combo.currentData()
self.image_combo.blockSignals(False) self._ports = list_serial_ports()
self._update() self.port_combo.clear()
for p in self._ports:
def _import(self) -> None: self.port_combo.addItem(p.label, p.device)
path, _ = QFileDialog.getOpenFileName(self, "Import firmware from the firmware team", str(Path.home()), if not self._ports:
"Firmware (*.bin);;All files (*)") self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
if not path: self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
return self._update()
try:
img = self._library.import_file(Path(path), self._reference) def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
except (ImageError, OSError) as exc: self.refresh_ports()
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}") super().showEvent(event)
return
self._load_images(select_sha=img.sha256) # ------------------------------------------------------------ state
kind = "complete image" if img.kind == "merged" else "app only" def _selected_image(self) -> FirmwareImage | None:
QMessageBox.information( i = self.image_combo.currentIndex()
self, "Firmware imported", return self._images[i] if 0 <= i < len(self._images) else None
f"{img.name}\nbuild {img.app.elf_sha[:9]}, {kind}\n\nFile fingerprint (SHA-256):\n{img.sha256}\n\n"
"Compare it with the fingerprint the firmware team sent you before flashing.") def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData()
def _remove(self) -> None: return next((p for p in self._ports if p.device == dev), None)
img = self._selected_image()
if img and img.is_developer and QMessageBox.question( def _update(self) -> None:
self, "Remove firmware", f"Remove {img.name} from this computer?") == QMessageBox.StandardButton.Yes: img = self._selected_image()
self._library.remove(img.sha256) info = ""
self._load_images() if img:
a = img.app
def refresh_ports(self) -> None: kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
current = self.port_combo.currentData() info = (f"<b>Compiled {a.built}</b> &middot; {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
self._ports = list_serial_ports() f"<small>file firmware/{img.name} &middot; build {a.elf_sha[:8]} &middot; "
self.port_combo.clear() f"SHA-256 {img.sha256[:16]}... &middot; checksum OK</small>")
for p in self._ports: else:
self.port_combo.addItem(p.label, p.device) info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>"
if not self._ports: "and press <b>Refresh list</b>. Only files in this folder can be flashed.")
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) if self._problems:
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0)) info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
self._update() + "; ".join(self._problems) + "</span>")
self.image_info.setText(info)
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened # An app-only file has no bootloader/layout: Full mode is impossible.
self.refresh_ports() full_ok = bool(img and img.kind == "merged")
super().showEvent(event) self.rb_full.setEnabled(full_ok and self._runner is None)
if not full_ok and self.rb_full.isChecked():
# ------------------------------------------------------------ state self.rb_app.setChecked(True)
def _selected_image(self) -> FirmwareImage | None: self.box_fw.setText(self._box_text(img))
i = self.image_combo.currentIndex()
return self._images[i] if 0 <= i < len(self._images) else None self.confirm_full.setVisible(self.rb_full.isChecked())
ok = bool(img and self._selected_port() and self._runner is None
def _selected_port(self) -> PortInfo | None: and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
dev = self.port_combo.currentData() self.start_btn.setEnabled(ok)
return next((p for p in self._ports if p.device == dev), None) if img and self._selected_port():
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
def _update(self) -> None: steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
img = self._selected_image() if mode == Mode.APP_ONLY:
info = "" steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
if img: else:
a = img.app steps += ["Write the complete image (~25 s)"]
origin = ("<b>Factory image</b> (bundled with the tool)" if not img.is_developer else steps += ["Restart the box and verify the new firmware boots"]
"<b style='color:#c77c02'>Developer build</b> - only flash builds you received " self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
"from the firmware team") else:
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only" self.plan_label.setText("Select a sensorbox and a firmware image.")
info = (f"{origin}<br>{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}, {kind}<br>"
f"<small>build {a.elf_sha[:9]} &middot; file SHA-256 {img.sha256[:16]}... " def _box_text(self, img: FirmwareImage | None) -> str:
f"&middot; checksum OK</small>" + (f"<br><small>{img.notes}</small>" if img.notes else "")) fw = self.serial_tab.engine.firmware
if self._problems: if not fw.elf_sha:
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> " return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
+ "; ".join(self._problems) + "</span>") msg = f"Box currently runs: {fw.describe()}"
self.image_info.setText(info) if not img:
self.remove_btn.setEnabled(bool(img and img.is_developer) and self._runner is None) return msg
# An app-only file has no bootloader/layout: Full mode is impossible. if same_build(fw.elf_sha, img.app.elf_sha):
full_ok = bool(img and img.kind == "merged") return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
self.rb_full.setEnabled(full_ok and self._runner is None) local = local_build(fw.elf_sha)
if not full_ok and self.rb_full.isChecked(): box_built, img_built = _parse_built(fw.built or (local[1] if local else None)), img.app.built_at
self.rb_app.setChecked(True) if box_built and img_built and img_built > box_built:
self.box_fw.setText(self._box_text(img)) return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>"
if box_built and img_built and img_built < box_built:
self.confirm_full.setVisible(self.rb_full.isChecked()) return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was "
ok = bool(img and self._selected_port() and self._runner is None "probably updated over OTA). The box updates itself again once online.</b>")
and (self.rb_app.isChecked() or self.confirm_full.isChecked())) return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>"
self.start_btn.setEnabled(ok)
if img and self._selected_port(): # ------------------------------------------------------------ flash
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL def _start(self) -> None:
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else [] img, port = self._selected_image(), self._selected_port()
if mode == Mode.APP_ONLY: if not img or not port:
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"] return
else: mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
steps += ["Write the complete factory image (~25 s)"] what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
steps += ["Restart the box and verify the new firmware boots"] else "the COMPLETE image (pairing and OTA registration are erased)")
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>") if QMessageBox.question(self, "Flash firmware",
else: f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\n"
self.plan_label.setText("Select a sensorbox and a firmware image.") f"File: firmware/{img.name}\nBox: {port.label}\n\n"
"Keep the box powered and the USB cable connected until it finishes.",
def _box_text(self, img: FirmwareImage | None) -> str: QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
fw = self.serial_tab.engine.firmware ) != QMessageBox.StandardButton.Ok:
if not fw.elf_sha: return
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it." backup_to = None
msg = f"Box currently runs: {fw.describe()}" + (f", built {fw.built}" if fw.built else "") if self.backup.isChecked():
if not img: BACKUP_DIR.mkdir(parents=True, exist_ok=True)
return msg stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
if fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha): backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>" plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
box_built, img_built = _parse_built(fw.built), img.app.built_at
if box_built and img_built and img_built > box_built: self._port, self._image = port, img
return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>" self.serial_tab.release_port() # esptool needs the port exclusively
if box_built and img_built and img_built < box_built: self.output.clear()
return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was " self.output.appendPlainText("\n".join(plan.summary()))
"probably updated over OTA). The box updates itself again once online.</b>") self._show_result("", None)
return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>" self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
self._bridge.output.emit, self._bridge.done.emit)
# ------------------------------------------------------------ flash self._set_busy(True)
def _start(self) -> None: self._runner.start()
img, port = self._selected_image(), self._selected_port()
if not img or not port: def _set_busy(self, busy: bool) -> None:
return for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full,
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL self.reload_btn):
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY w.setEnabled(not busy)
else "the COMPLETE factory image (pairing and OTA registration are erased)") self._update()
if QMessageBox.question(self, "Flash firmware",
f"Write {what}\n\nImage: {img.name}\nBox: {port.label}\n\n" def _on_step(self, i: int, title: str) -> None:
"Keep the box powered and the USB cable connected until it finishes.", n = len(self._runner.plan.steps) if self._runner else 0
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
) != QMessageBox.StandardButton.Ok:
return def _on_done(self, res: Result) -> None:
backup_to = None self._runner = None
if self.backup.isChecked(): if not res.ok:
BACKUP_DIR.mkdir(parents=True, exist_ok=True) self._set_busy(False)
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") self.step_label.setText("Stopped.")
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin" hint = f"<p>{res.hint}</p>" if res.hint else ""
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to) self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
self.details_btn.setChecked(True)
self._port, self._image = port, img return
self.serial_tab.release_port() # esptool needs the port exclusively self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
self.output.clear() self.bar.setValue(0)
self.output.appendPlainText("\n".join(plan.summary())) self._show_result("Firmware written. Waiting for the box to start...", "info")
self._show_result("", None) self.serial_tab.connect_port(self._port, capture=True)
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit, self._verify_ms = 0
self._bridge.output.emit, self._bridge.done.emit) self._verify_timer.start()
self._set_busy(True)
self._runner.start() def _check_verify(self) -> None:
self._verify_ms += self._verify_timer.interval()
def _set_busy(self, busy: bool) -> None: self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full, self.import_btn): fw = self.serial_tab.engine.firmware
w.setEnabled(not busy) want = self._image.app.elf_sha[:8]
self._update() done = same_build(fw.elf_sha, self._image.app.elf_sha)
if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
def _on_step(self, i: int, title: str) -> None: self._verify_timer.stop()
n = len(self._runner.plan.steps) if self._runner else 0 self._set_busy(False)
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}") self.bar.setValue(100)
if done:
def _on_done(self, res: Result) -> None: self.step_label.setText("Done.")
self._runner = None self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
if not res.ok: "Check the Diagnosis panel in the Serial monitor.", "ok")
self._set_busy(False) else:
self.step_label.setText("Stopped.") seen = fw.elf_sha[:8] if fw.elf_sha else "no boot seen"
hint = f"<p>{res.hint}</p>" if res.hint else "" self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error") "Open the Serial monitor and press Reset & capture.", "warning")
self.details_btn.setChecked(True)
return def _show_result(self, html: str, kind: str | None) -> None:
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...") colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
self.bar.setValue(0) "warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
self._show_result("Firmware written. Waiting for the box to start...", "info") self.result.setVisible(bool(html))
self.serial_tab.connect_port(self._port, capture=True) if html:
self._verify_ms = 0 border, bg = colours[kind or "info"]
self._verify_timer.start() self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html)
def _check_verify(self) -> None:
self._verify_ms += self._verify_timer.interval()
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS))) def _parse_built(s: str | None) -> dt.datetime | None:
fw = self.serial_tab.engine.firmware """'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
want = self._image.app.elf_sha[:9] if not s:
done = fw.elf_sha is not None and fw.elf_sha.startswith(want) return None
if done or self._verify_ms >= VERIFY_TIMEOUT_MS: try:
self._verify_timer.stop() return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
self._set_busy(False) except ValueError:
self.bar.setValue(100) return None
if done:
self.step_label.setText("Done.")
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
"Check the Diagnosis panel in the Serial monitor.", "ok")
else:
seen = fw.elf_sha[:9] if fw.elf_sha else "no boot seen"
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
"Open the Serial monitor and press Reset & capture.", "warning")
def _show_result(self, html: str, kind: str | None) -> None:
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
self.result.setVisible(bool(html))
if html:
border, bg = colours[kind or "info"]
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html)
def _parse_built(s: str | None) -> dt.datetime | None:
"""'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
if not s:
return None
try:
return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
except ValueError:
return None
+225 -216
View File
@@ -1,216 +1,225 @@
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner.""" """Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QProgressBar, QProgressBar,
QPushButton, QPushButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from ..diag.engine import LED, DiagnosticsEngine from ..diag.engine import LED, DiagnosticsEngine
from ..diag.guides import GuideRunner, recommended from ..diag.guides import GuideRunner, recommended
from ..matter.payload import SetupPayload
END_STYLE = { # end severity -> (border, background) from .matter_qr import MatterQrWidget
"ok": ("#2e9e44", "#e3f4e7"),
"info": ("#2e6da4", "#e3edf7"), END_STYLE = { # end severity -> (border, background)
"warning": ("#c77c02", "#fdf0d6"), "ok": ("#2e9e44", "#e3f4e7"),
"error": ("#d9534f", "#f9e0df"), "info": ("#2e6da4", "#e3edf7"),
"critical": ("#b03a2e", "#f6d5d1"), "warning": ("#c77c02", "#fdf0d6"),
} "error": ("#d9534f", "#f9e0df"),
"critical": ("#b03a2e", "#f6d5d1"),
}
def _swatch(key: str, size: int = 22) -> QIcon:
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
pm = QPixmap(size, size) def _swatch(key: str, size: int = 22) -> QIcon:
pm.fill(QColor(0, 0, 0, 0)) """Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
p = QPainter(pm) pm = QPixmap(size, size)
p.setRenderHint(QPainter.RenderHint.Antialiasing) pm.fill(QColor(0, 0, 0, 0))
if key == "CYCLING": p = QPainter(pm)
g = QLinearGradient(0, 0, size, 0) p.setRenderHint(QPainter.RenderHint.Antialiasing)
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")): if key == "CYCLING":
g.setColorAt(stop, QColor(c)) g = QLinearGradient(0, 0, size, 0)
p.setBrush(QBrush(g)) for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
else: g.setColorAt(stop, QColor(c))
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1])) p.setBrush(QBrush(g))
p.setPen(QColor("#777777")) else:
p.drawEllipse(1, 1, size - 2, size - 2) p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
p.end() p.setPen(QColor("#777777"))
return QIcon(pm) p.drawEllipse(1, 1, size - 2, size - 2)
p.end()
return QIcon(pm)
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class GuidePanel(QWidget):
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None):
super().__init__(parent) class GuidePanel(QWidget):
self.runner, self.engine = runner, engine def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None,
self._shown_key: tuple | None = None matter_payload=None):
super().__init__(parent)
# --- picker (no guide running) self.runner, self.engine = runner, engine
self.picker = QWidget() self._matter_payload = matter_payload or SetupPayload # callable -> SetupPayload
pl = QVBoxLayout(self.picker) self._shown_key: tuple | None = None
pl.setContentsMargins(0, 0, 0, 0)
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):")) # --- picker (no guide running)
self.guide_list = QListWidget() self.picker = QWidget()
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole))) pl = QVBoxLayout(self.picker)
pl.addWidget(self.guide_list, 1) pl.setContentsMargins(0, 0, 0, 0)
self.start_btn = QPushButton("Start selected check") pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
self.start_btn.clicked.connect(self._start_selected) self.guide_list = QListWidget()
pl.addWidget(self.start_btn) self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
pl.addWidget(self.guide_list, 1)
# --- running guide self.start_btn = QPushButton("Start selected check")
self.card = QFrame() self.start_btn.clicked.connect(self._start_selected)
self.card.setFrameShape(QFrame.Shape.StyledPanel) pl.addWidget(self.start_btn)
cl = QVBoxLayout(self.card)
self.guide_title = QLabel() # --- running guide
self.guide_title.setStyleSheet("color:#666") self.card = QFrame()
self.step_text = QLabel(wordWrap=True) self.card.setFrameShape(QFrame.Shape.StyledPanel)
f = self.step_text.font() cl = QVBoxLayout(self.card)
f.setPointSize(f.pointSize() + 2) self.guide_title = QLabel()
self.step_text.setFont(f) self.guide_title.setStyleSheet("color:#666")
self.step_text.setTextFormat(Qt.TextFormat.RichText) self.step_text = QLabel(wordWrap=True)
self.options = QVBoxLayout() f = self.step_text.font()
self.wait_bar = QProgressBar() f.setPointSize(f.pointSize() + 2)
self.wait_bar.setTextVisible(False) self.step_text.setFont(f)
self.wait_label = QLabel() self.step_text.setTextFormat(Qt.TextFormat.RichText)
self.note = QLabel(wordWrap=True) self.options = QVBoxLayout()
self.note.setStyleSheet("color:#b03a2e") self.wait_bar = QProgressBar()
self.continue_btn = QPushButton("Continue") self.wait_bar.setTextVisible(False)
self.continue_btn.clicked.connect(self._proceed) self.wait_label = QLabel()
self.skip_btn = QPushButton("Skip waiting / it did not work") self.note = QLabel(wordWrap=True)
self.skip_btn.clicked.connect(self._proceed) self.note.setStyleSheet("color:#b03a2e")
self.end_box = QLabel(wordWrap=True) self.continue_btn = QPushButton("Continue")
self.end_box.setTextFormat(Qt.TextFormat.RichText) self.continue_btn.clicked.connect(self._proceed)
self.end_box.setMargin(10) self.skip_btn = QPushButton("Skip waiting / it did not work")
for w in (self.guide_title, self.step_text): self.skip_btn.clicked.connect(self._proceed)
cl.addWidget(w) self.end_box = QLabel(wordWrap=True)
cl.addLayout(self.options) self.end_box.setTextFormat(Qt.TextFormat.RichText)
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box): self.end_box.setMargin(10)
cl.addWidget(w) self.qr = MatterQrWidget(SetupPayload(), 220)
cl.addStretch(1) for w in (self.guide_title, self.step_text, self.qr):
nav = QHBoxLayout() cl.addWidget(w)
self.back_btn = QPushButton("← Back") cl.addLayout(self.options)
self.back_btn.clicked.connect(self._back) for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
self.done_btn = QPushButton("Back to checks list") cl.addWidget(w)
self.done_btn.clicked.connect(self._cancel) cl.addStretch(1)
nav.addWidget(self.back_btn) nav = QHBoxLayout()
nav.addStretch(1) self.back_btn = QPushButton("← Back")
nav.addWidget(self.done_btn) self.back_btn.clicked.connect(self._back)
cl.addLayout(nav) self.done_btn = QPushButton("Back to checks list")
self.done_btn.clicked.connect(self._cancel)
lay = QVBoxLayout(self) nav.addWidget(self.back_btn)
lay.setContentsMargins(4, 0, 0, 0) nav.addStretch(1)
lay.addWidget(self.picker, 1) nav.addWidget(self.done_btn)
lay.addWidget(self.card, 1) cl.addLayout(nav)
self.refresh()
lay = QVBoxLayout(self)
# ------------------------------------------------------------ actions lay.setContentsMargins(4, 0, 0, 0)
def _start_selected(self) -> None: lay.addWidget(self.picker, 1)
it = self.guide_list.currentItem() or self.guide_list.item(0) lay.addWidget(self.card, 1)
if it: self.refresh()
self._start(it.data(Qt.ItemDataRole.UserRole))
# ------------------------------------------------------------ actions
def _start(self, guide_id: str) -> None: def _start_selected(self) -> None:
self.runner.start(guide_id) it = self.guide_list.currentItem() or self.guide_list.item(0)
self.refresh() if it:
self._start(it.data(Qt.ItemDataRole.UserRole))
def _answer(self, key: str) -> None:
self.runner.answer(key) def _start(self, guide_id: str) -> None:
self.refresh() self.runner.start(guide_id)
self.refresh()
def _proceed(self) -> None:
self.runner.proceed() def _answer(self, key: str) -> None:
self.refresh() self.runner.answer(key)
self.refresh()
def _back(self) -> None:
self.runner.back() def _proceed(self) -> None:
self.refresh() self.runner.proceed()
self.refresh()
def _cancel(self) -> None:
self.runner.cancel() def _back(self) -> None:
self.refresh() self.runner.back()
self.refresh()
# ------------------------------------------------------------- render
def refresh(self) -> None: def _cancel(self) -> None:
"""Called after every interaction and periodically (timeouts, countdown).""" self.runner.cancel()
self.runner.tick() self.refresh()
v = self.runner.view()
running = v.guide is not None # ------------------------------------------------------------- render
self.picker.setVisible(not running) def refresh(self) -> None:
self.card.setVisible(running) """Called after every interaction and periodically (timeouts, countdown)."""
if not running: self.runner.tick()
self._fill_picker() v = self.runner.view()
self._shown_key = None running = v.guide is not None
return self.picker.setVisible(not running)
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None) self.card.setVisible(running)
if key != self._shown_key: if not running:
self._shown_key = key self._fill_picker()
self._render_step(v) self._shown_key = None
if v.waiting: return
total = v.step.timeout_s key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
self.wait_bar.setMaximum(int(total)) if key != self._shown_key:
self.wait_bar.setValue(int(total - v.remaining_s)) self._shown_key = key
m, s = divmod(int(v.remaining_s), 60) self._render_step(v)
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left") if v.waiting:
self.note.setText(v.note) total = v.step.timeout_s
self.note.setVisible(bool(v.note)) self.wait_bar.setMaximum(int(total))
self.back_btn.setEnabled(v.can_back) self.wait_bar.setValue(int(total - v.remaining_s))
m, s = divmod(int(v.remaining_s), 60)
def _fill_picker(self) -> None: self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)] self.note.setText(v.note)
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())] self.note.setVisible(bool(v.note))
if [w[0] for w in wanted] == current: self.back_btn.setEnabled(v.can_back)
return
self.guide_list.clear() def _fill_picker(self) -> None:
for gid, title, summary in wanted: wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
it = QListWidgetItem(f"{title}\n {summary}") current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
it.setData(Qt.ItemDataRole.UserRole, gid) if [w[0] for w in wanted] == current:
self.guide_list.addItem(it) return
self.guide_list.setCurrentRow(0) self.guide_list.clear()
for gid, title, summary in wanted:
def _render_step(self, v) -> None: it = QListWidgetItem(f"{title}\n {summary}")
self.guide_title.setText(v.guide.title) it.setData(Qt.ItemDataRole.UserRole, gid)
while self.options.count(): self.guide_list.addItem(it)
w = self.options.takeAt(0).widget() self.guide_list.setCurrentRow(0)
if w:
# Detach now; deleteLater alone leaves it visible until the def _render_step(self, v) -> None:
# event loop processes the deferred delete. self.guide_title.setText(v.guide.title)
w.hide() while self.options.count():
w.setParent(None) w = self.options.takeAt(0).widget()
w.deleteLater() if w:
is_end = v.end is not None # Detach now; deleteLater alone leaves it visible until the
self.step_text.setVisible(not is_end) # event loop processes the deferred delete.
self.step_text.setText(_esc(v.text)) w.hide()
for opt_key, label in v.options: w.setParent(None)
b = QPushButton(label) w.deleteLater()
b.setMinimumHeight(34) is_end = v.end is not None
if v.step and v.step.type == "led": self.step_text.setVisible(not is_end)
b.setIcon(_swatch(opt_key)) self.step_text.setText(_esc(v.text))
b.setIconSize(QSize(22, 22)) show_qr = bool(v.step and v.step.show_qr)
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }") if show_qr:
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k)) self.qr.set_payload(self._matter_payload())
self.options.addWidget(b) self.qr.setVisible(show_qr)
step_type = v.step.type if v.step else None for opt_key, label in v.options:
self.continue_btn.setVisible(step_type == "info") b = QPushButton(label)
self.skip_btn.setVisible(step_type == "wait") b.setMinimumHeight(34)
self.wait_bar.setVisible(step_type == "wait") if v.step and v.step.type == "led":
self.wait_label.setVisible(step_type == "wait") b.setIcon(_swatch(opt_key))
self.end_box.setVisible(is_end) b.setIconSize(QSize(22, 22))
if is_end: b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
e = v.end b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
border, bg = END_STYLE.get(e.severity, END_STYLE["info"]) self.options.addWidget(b)
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions) step_type = v.step.type if v.step else None
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}") self.continue_btn.setVisible(step_type == "info")
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>" self.skip_btn.setVisible(step_type == "wait")
+ (f"<ol>{acts}</ol>" if acts else "")) self.wait_bar.setVisible(step_type == "wait")
self.wait_label.setVisible(step_type == "wait")
self.end_box.setVisible(is_end)
if is_end:
e = v.end
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
+ (f"<ol>{acts}</ol>" if acts else ""))
+424 -408
View File
@@ -1,408 +1,424 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QFileDialog, QFileDialog,
QHBoxLayout, QHBoxLayout,
QInputDialog, QInputDialog,
QLabel, QLabel,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QPushButton, QPushButton,
QSplitter, QSplitter,
QTabWidget, QTabWidget,
QTextBrowser, QTextBrowser,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from .. import __version__ from .. import __version__
from ..core.logfile import read_log_file from ..core.logfile import read_log_file
from ..core.logline import LineKind, LogLine, host_event from ..core.logline import LineKind, LogLine, host_event
from ..core.session import SessionLog from ..core.session import SessionLog
from ..diag.engine import DiagnosticsEngine from ..diag.engine import DiagnosticsEngine
from ..diag.guides import GuideRunner from ..diag.guides import GuideRunner
from ..serialio.manager import SerialManager, State from ..serialio.manager import SerialManager, State
from ..serialio.ports import PortInfo, list_serial_ports from ..serialio.ports import PortInfo, list_serial_ports
from ..service.report import DEFAULT_BASE, normalize_mac, report_url from ..matter.payload import SetupPayload, payload_from_log
from .cheatsheet import cheatsheet_html from ..service.report import DEFAULT_BASE, normalize_mac, report_url
from .diag_panel import DiagnosticsPanel from .cheatsheet import cheatsheet_html
from .flash_tab import FlashTab from .diag_panel import DiagnosticsPanel
from .guide_panel import GuidePanel from .flash_tab import FlashTab
from .sd_tab import SdTab from .matter_qr import MatterQrDialog
from .guide_panel import GuidePanel
MAX_LINES = 20000 from .sd_tab import SdTab
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
COLORS = { MAX_LINES = 20000
"E": "#ff6b6b", # "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
"W": "#f5c451", LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
"I": "#d4d4d4", "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
LineKind.BOOT: "#6fc3ff", COLORS = {
LineKind.RESET: "#6fc3ff", "E": "#ff6b6b",
LineKind.PANIC: "#ff4dd2", "W": "#f5c451",
LineKind.HOST: "#7ee07e", "I": "#d4d4d4",
LineKind.RAW: "#a8a8a8", LineKind.BOOT: "#6fc3ff",
} LineKind.RESET: "#6fc3ff",
LineKind.PANIC: "#ff4dd2",
LineKind.HOST: "#7ee07e",
class _Bridge(QObject): LineKind.KOTA: "#5fd7e8",
"""Marshals SerialManager callbacks (reader thread) onto the UI thread.""" LineKind.RAW: "#a8a8a8",
}
line = Signal(object)
state = Signal(object, str)
class _Bridge(QObject):
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
class SerialTab(QWidget):
def __init__(self, parent: QWidget | None = None): line = Signal(object)
super().__init__(parent) state = Signal(object, str)
self._bridge = _Bridge()
self._bridge.line.connect(self._on_line)
self._bridge.state.connect(self._on_state) class SerialTab(QWidget):
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit) def __init__(self, parent: QWidget | None = None):
self._session = SessionLog() super().__init__(parent)
self._ports: list[PortInfo] = [] self._bridge = _Bridge()
self._counts = {"E": 0, "W": 0, "boots": 0} self._bridge.line.connect(self._on_line)
self._engine = DiagnosticsEngine() self._bridge.state.connect(self._on_state)
self._diag_dirty = True self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log) self._session = SessionLog()
self._ports: list[PortInfo] = []
# --- controls self._counts = {"E": 0, "W": 0, "boots": 0}
self.port_combo = QComboBox(minimumWidth=320) self._engine = DiagnosticsEngine()
self.all_ports = QCheckBox("Show all ports") self._diag_dirty = True
self.refresh_btn = QPushButton("Refresh") self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
self.connect_btn = QPushButton("Connect")
self.reset_btn = QPushButton("Reset && capture") # --- controls
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line") self.port_combo = QComboBox(minimumWidth=320)
self.filter_combo = QComboBox() self.all_ports = QCheckBox("Show all ports")
self.filter_combo.addItems(LEVEL_FILTERS) self.refresh_btn = QPushButton("Refresh")
self.clear_btn = QPushButton("Clear view") self.connect_btn = QPushButton("Connect")
self.save_btn = QPushButton("Save view as...") self.reset_btn = QPushButton("Reset && capture")
self.folder_btn = QPushButton("Open logs folder") self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
self.analyze_btn = QPushButton("Analyze log file...") self.filter_combo = QComboBox()
self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it") self.filter_combo.addItems(LEVEL_FILTERS)
self.copy_btn = QPushButton("Copy report") self.clear_btn = QPushButton("Clear view")
self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)") self.save_btn = QPushButton("Save view as...")
self.service_btn = QPushButton("Service report...") self.folder_btn = QPushButton("Open logs folder")
self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis " self.analyze_btn = QPushButton("Analyze log file...")
"filled in; check it and press Save there") self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it")
self._settings = QSettings("KOTA", "TechnicianTool") self.copy_btn = QPushButton("Copy report")
self.auto_service = QCheckBox("Open service report on disconnect") self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)")
self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool)) self.service_btn = QPushButton("Service report...")
self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on)) self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis "
self._active_port: PortInfo | None = None "filled in; check it and press Save there")
self._settings = QSettings("KOTA", "TechnicianTool")
row1 = QHBoxLayout() self.auto_service = QCheckBox("Open service report on disconnect")
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn, self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool))
self.connect_btn, self.reset_btn): self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on))
row1.addWidget(w) self._active_port: PortInfo | None = None
row1.addStretch(1)
row2 = QHBoxLayout() row1 = QHBoxLayout()
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn, for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service): self.connect_btn, self.reset_btn):
row2.addWidget(w) row1.addWidget(w)
row2.addStretch(1) row1.addStretch(1)
row2 = QHBoxLayout()
self.view = QPlainTextEdit(readOnly=True) for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
self.view.setMaximumBlockCount(MAX_LINES) self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service):
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) row2.addWidget(w)
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas") row2.addStretch(1)
mono.setStyleHint(QFont.StyleHint.Monospace)
mono.setPointSize(10) self.view = QPlainTextEdit(readOnly=True)
self.view.setFont(mono) self.view.setMaximumBlockCount(MAX_LINES)
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }") self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
self.diag = DiagnosticsPanel() mono.setStyleHint(QFont.StyleHint.Monospace)
self.guide = GuidePanel(self._runner, self._engine) mono.setPointSize(10)
self.side_tabs = QTabWidget() self.view.setFont(mono)
self.side_tabs.addTab(self.diag, "Diagnosis") self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
split = QSplitter(Qt.Orientation.Horizontal) self.diag = DiagnosticsPanel()
split.addWidget(self.view) self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
split.addWidget(self.side_tabs) self.side_tabs = QTabWidget()
split.setStretchFactor(0, 3) self.side_tabs.addTab(self.diag, "Diagnosis")
split.setStretchFactor(1, 2) self.side_tabs.addTab(self.guide, "Guided troubleshooting")
split.setSizes([700, 480]) split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(self.view)
self.status = QLabel() split.addWidget(self.side_tabs)
layout = QVBoxLayout(self) split.setStretchFactor(0, 3)
layout.addLayout(row1) split.setStretchFactor(1, 2)
layout.addLayout(row2) split.setSizes([700, 480])
layout.addWidget(split, 1)
layout.addWidget(self.status) self.status = QLabel()
layout = QVBoxLayout(self)
# --- wiring layout.addLayout(row1)
self.refresh_btn.clicked.connect(self.refresh_ports) layout.addLayout(row2)
self.all_ports.toggled.connect(self.refresh_ports) layout.addWidget(split, 1)
self.connect_btn.clicked.connect(self._toggle_connect) layout.addWidget(self.status)
self.reset_btn.clicked.connect(self._manager.reset_board)
self.clear_btn.clicked.connect(self.view.clear) # --- wiring
self.save_btn.clicked.connect(self._save_view) self.refresh_btn.clicked.connect(self.refresh_ports)
self.folder_btn.clicked.connect(self._open_folder) self.all_ports.toggled.connect(self.refresh_ports)
self.analyze_btn.clicked.connect(self._analyze_file) self.connect_btn.clicked.connect(self._toggle_connect)
self.copy_btn.clicked.connect(self._copy_report) self.reset_btn.clicked.connect(self._manager.reset_board)
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False)) self.clear_btn.clicked.connect(self.view.clear)
self.save_btn.clicked.connect(self._save_view)
# Report refresh is throttled; lines can arrive at >100/s. self.folder_btn.clicked.connect(self._open_folder)
self._diag_timer = QTimer(self, interval=400) self.analyze_btn.clicked.connect(self._analyze_file)
self._diag_timer.timeout.connect(self._refresh_diag) self.copy_btn.clicked.connect(self._copy_report)
self._diag_timer.start() self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False))
self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
# Auto-detect a box being plugged in while idle.
self._poll = QTimer(self, interval=2000) # Report refresh is throttled; lines can arrive at >100/s.
self._poll.timeout.connect(self._auto_refresh) self._diag_timer = QTimer(self, interval=400)
self._poll.start() self._diag_timer.timeout.connect(self._refresh_diag)
self._diag_timer.start()
self.refresh_ports()
self._on_state(State.DISCONNECTED, "") # Auto-detect a box being plugged in while idle.
self._poll = QTimer(self, interval=2000)
# ---- ports ------------------------------------------------------- self._poll.timeout.connect(self._auto_refresh)
def refresh_ports(self) -> None: self._poll.start()
current = self.port_combo.currentData()
self._ports = list_serial_ports(include_all=self.all_ports.isChecked()) self.refresh_ports()
self.port_combo.clear() self._on_state(State.DISCONNECTED, "")
for p in self._ports:
self.port_combo.addItem(p.label, p.device) # ---- ports -------------------------------------------------------
if not self._ports: def refresh_ports(self) -> None:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) current = self.port_combo.currentData()
idx = self.port_combo.findData(current) self._ports = list_serial_ports(include_all=self.all_ports.isChecked())
self.port_combo.setCurrentIndex(max(idx, 0)) self.port_combo.clear()
for p in self._ports:
def _auto_refresh(self) -> None: self.port_combo.addItem(p.label, p.device)
if self._manager.is_running: if not self._ports:
return self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())] idx = self.port_combo.findData(current)
if devices != [p.device for p in self._ports]: self.port_combo.setCurrentIndex(max(idx, 0))
self.refresh_ports()
def _auto_refresh(self) -> None:
def _selected_port(self) -> PortInfo | None: if self._manager.is_running:
dev = self.port_combo.currentData() return
return next((p for p in self._ports if p.device == dev), None) devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())]
if devices != [p.device for p in self._ports]:
# ---- connection -------------------------------------------------- self.refresh_ports()
def _toggle_connect(self) -> None:
if self._manager.is_running: def _selected_port(self) -> PortInfo | None:
# Session file is closed in _on_state(DISCONNECTED), after the dev = self.port_combo.currentData()
# reader thread's final queued lines have been written. return next((p for p in self._ports if p.device == dev), None)
self._manager.disconnect()
return # ---- connection --------------------------------------------------
port = self._selected_port() def _toggle_connect(self) -> None:
if port is None: if self._manager.is_running:
return # Session file is closed in _on_state(DISCONNECTED), after the
self._connect(port) # reader thread's final queued lines have been written.
self._manager.disconnect()
def _connect(self, port: PortInfo) -> None: return
self._counts = {"E": 0, "W": 0, "boots": 0} port = self._selected_port()
self._engine.reset() if port is None:
self._runner.cancel() return
self._diag_dirty = True self._connect(port)
self._session.start(port.mac or port.device)
self._active_port = port def _connect(self, port: PortInfo) -> None:
self._manager.connect(port) self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine.reset()
# ---- service system ------------------------------------------------- self._runner.cancel()
def _service_base(self) -> str: self._diag_dirty = True
return str(self._settings.value("service/base", DEFAULT_BASE)) self._session.start(port.mac or port.device)
self._active_port = port
def _open_service_report(self, auto: bool) -> None: self._manager.connect(port)
mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "")
if len(mac) != 12: def matter_payload(self) -> SetupPayload:
if not auto: """Pairing payload reported by this box (KOTA: matter), from its log, or the defaults."""
QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. " return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [],
"Connect to the box and press Reset & capture first.") self._engine.matter_qr, self._engine.matter_manual)
return
url = report_url(mac, self._engine.report(), self._service_base()) # ---- service system -------------------------------------------------
QDesktopServices.openUrl(QUrl(url)) def _service_base(self) -> str:
# Log that it happened, not the report itself (it is already in the session log). return str(self._settings.value("service/base", DEFAULT_BASE))
self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there")
def _open_service_report(self, auto: bool) -> None:
# ---- hand-off with the flash tab ------------------------------------ mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "")
@property if len(mac) != 12:
def engine(self) -> DiagnosticsEngine: if not auto:
return self._engine QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. "
"Connect to the box and press Reset & capture first.")
def release_port(self) -> None: return
"""Stop monitoring so esptool can open the port exclusively.""" url = report_url(mac, self._engine.report(), self._service_base())
if self._manager.is_running: QDesktopServices.openUrl(QUrl(url))
self._no_auto_service = True # a flash hand-off is not the end of a session # Log that it happened, not the report itself (it is already in the session log).
self._manager.disconnect() # joins the reader thread self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there")
QCoreApplication.processEvents() # deliver its queued final lines/state now
self._no_auto_service = False # ---- hand-off with the flash tab ------------------------------------
@property
def connect_port(self, port: PortInfo, capture: bool = True) -> None: def engine(self) -> DiagnosticsEngine:
"""Monitor `port`; with capture, restart the box once connected to record its boot.""" return self._engine
self.release_port()
idx = self.port_combo.findData(port.device) def release_port(self) -> None:
if idx < 0: """Stop monitoring so esptool can open the port exclusively."""
self.port_combo.addItem(port.label, port.device) if self._manager.is_running:
self._ports.append(port) self._no_auto_service = True # a flash hand-off is not the end of a session
idx = self.port_combo.count() - 1 self._manager.disconnect() # joins the reader thread
self.port_combo.setCurrentIndex(idx) QCoreApplication.processEvents() # deliver its queued final lines/state now
self._reset_on_connect = capture self._no_auto_service = False
self._connect(port)
def connect_port(self, port: PortInfo, capture: bool = True) -> None:
def _on_state(self, state: State, detail: str) -> None: """Monitor `port`; with capture, restart the box once connected to record its boot."""
running = state != State.DISCONNECTED self.release_port()
if not running and self._manager.is_running: idx = self.port_combo.findData(port.device)
return # stale event from a session that was already replaced if idx < 0:
if state == State.CONNECTED and getattr(self, "_reset_on_connect", False): self.port_combo.addItem(port.label, port.device)
self._reset_on_connect = False self._ports.append(port)
QTimer.singleShot(300, self._manager.reset_board) idx = self.port_combo.count() - 1
if not running and self._session.path is not None: self.port_combo.setCurrentIndex(idx)
self._session.write_report(self._engine.report().to_text()) self._reset_on_connect = capture
if (self.auto_service.isChecked() and self._engine.lines self._connect(port)
and not getattr(self, "_no_auto_service", False)):
self._open_service_report(auto=True) def _on_state(self, state: State, detail: str) -> None:
self._session.stop() running = state != State.DISCONNECTED
self.connect_btn.setText("Disconnect" if running else "Connect") if not running and self._manager.is_running:
self.reset_btn.setEnabled(state == State.CONNECTED) return # stale event from a session that was already replaced
self.port_combo.setEnabled(not running) if state == State.CONNECTED and getattr(self, "_reset_on_connect", False):
self.all_ports.setEnabled(not running) self._reset_on_connect = False
self.refresh_btn.setEnabled(not running) QTimer.singleShot(300, self._manager.reset_board)
# Offline analysis would mix with the live session; disconnect first. if not running and self._session.path is not None:
self.analyze_btn.setEnabled(not running) self._session.write_report(self._engine.report().to_text())
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "") if (self.auto_service.isChecked() and self._engine.lines
self._update_status() and not getattr(self, "_no_auto_service", False)):
self._open_service_report(auto=True)
def _update_status(self) -> None: self._session.stop()
sess = f" | Session log: {self._session.path}" if self._session.path else "" self.connect_btn.setText("Disconnect" if running else "Connect")
c = self._counts self.reset_btn.setEnabled(state == State.CONNECTED)
self.status.setText( self.port_combo.setEnabled(not running)
f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} " self.all_ports.setEnabled(not running)
f"errors: {c['E']} warnings: {c['W']}{sess}" self.refresh_btn.setEnabled(not running)
) # Offline analysis would mix with the live session; disconnect first.
self.analyze_btn.setEnabled(not running)
# ---- lines ------------------------------------------------------- self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
def _on_line(self, line: LogLine) -> None: self._update_status()
self._session.write(line)
self._engine.feed(line) def _update_status(self) -> None:
self._runner.feed(line) sess = f" | Session log: {self._session.path}" if self._session.path else ""
self._diag_dirty = True c = self._counts
if line.kind == LineKind.BOOT: self.status.setText(
self._counts["boots"] += 1 f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} "
if line.level in ("E", "W"): f"errors: {c['E']} warnings: {c['W']}{sess}"
self._counts[line.level] += 1 )
self._update_status()
# ---- lines -------------------------------------------------------
allowed = LEVEL_FILTERS[self.filter_combo.currentText()] def _on_line(self, line: LogLine) -> None:
if allowed is not None and line.kind != LineKind.HOST and not ( self._session.write(line)
line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET) self._engine.feed(line)
): self._runner.feed(line)
return self._diag_dirty = True
self._append(line) if line.kind == LineKind.BOOT:
self._counts["boots"] += 1
def _append(self, line: LogLine) -> None: if line.level in ("E", "W"):
sb = self.view.verticalScrollBar() self._counts[line.level] += 1
at_bottom = sb.value() >= sb.maximum() - 4 self._update_status()
fmt = QTextCharFormat()
fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4"))) if self._visible(line):
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST): self._append(line)
fmt.setFontWeight(QFont.Weight.Bold)
cur = QTextCursor(self.view.document()) def _visible(self, line: LogLine) -> bool:
cur.movePosition(QTextCursor.MoveOperation.End) allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
if not self.view.document().isEmpty(): if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET):
cur.insertBlock() return True
cur.insertText(line.text, fmt) if allowed == "KOTA":
if at_bottom: return line.kind == LineKind.KOTA or line.level == "E"
sb.setValue(sb.maximum()) return line.level in allowed
# ---- diagnostics ------------------------------------------------- def _append(self, line: LogLine) -> None:
def _refresh_diag(self) -> None: sb = self.view.verticalScrollBar()
if self._diag_dirty: at_bottom = sb.value() >= sb.maximum() - 4
self._diag_dirty = False fmt = QTextCharFormat()
self.diag.show_report(self._engine.report()) colour = COLORS.get(line.kind) if line.kind == LineKind.KOTA and line.level == "I" else None
self.guide.refresh() # countdowns and timeouts need a steady tick fmt.setForeground(QColor(colour or COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST) or line.record in ("boot", "status"):
# ---- guided troubleshooting hooks ------------------------------------ fmt.setFontWeight(QFont.Weight.Bold)
def _guide_action(self, action: str) -> bool: cur = QTextCursor(self.view.document())
if action == "reset" and self._manager.state == State.CONNECTED: cur.movePosition(QTextCursor.MoveOperation.End)
self._manager.reset_board() if not self.view.document().isEmpty():
return True cur.insertBlock()
return False cur.insertText(line.text, fmt)
if at_bottom:
def _guide_log(self, text: str) -> None: sb.setValue(sb.maximum())
# Technician answers go into the session log and the log view.
line = host_event(text) # ---- diagnostics -------------------------------------------------
self._session.write(line) def _refresh_diag(self) -> None:
self._append(line) if self._diag_dirty:
self._diag_dirty = True self._diag_dirty = False
self.diag.show_report(self._engine.report())
def _copy_report(self) -> None: self.guide.refresh() # countdowns and timeouts need a steady tick
QGuiApplication.clipboard().setText(self._engine.report().to_text())
# ---- guided troubleshooting hooks ------------------------------------
def _analyze_file(self) -> None: def _guide_action(self, action: str) -> bool:
path, _ = QFileDialog.getOpenFileName( if action == "reset" and self._manager.state == State.CONNECTED:
self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)") self._manager.reset_board()
if not path: return True
return return False
lines = read_log_file(Path(path))
self.view.clear() def _guide_log(self, text: str) -> None:
self._engine.reset() # Technician answers go into the session log and the log view.
self._runner.cancel() line = host_event(text)
self._counts = {"E": 0, "W": 0, "boots": 0} self._session.write(line)
for line in lines: self._append(line)
self._engine.feed(line) self._diag_dirty = True
if line.kind == LineKind.BOOT:
self._counts["boots"] += 1 def _copy_report(self) -> None:
if line.level in ("E", "W"): QGuiApplication.clipboard().setText(self._engine.report().to_text())
self._counts[line.level] += 1
self._append(line) def _analyze_file(self) -> None:
self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)" path, _ = QFileDialog.getOpenFileName(
self._update_status() self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)")
self._diag_dirty = True if not path:
self._refresh_diag() return
lines = read_log_file(Path(path))
# ---- misc -------------------------------------------------------- self.view.clear()
def _save_view(self) -> None: self._engine.reset()
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)") self._runner.cancel()
if path: self._counts = {"E": 0, "W": 0, "boots": 0}
with open(path, "w", encoding="utf-8") as fh: for line in lines:
fh.write(self.view.toPlainText()) self._engine.feed(line)
if line.kind == LineKind.BOOT:
def _open_folder(self) -> None: self._counts["boots"] += 1
self._session.root.mkdir(parents=True, exist_ok=True) if line.level in ("E", "W"):
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root))) self._counts[line.level] += 1
self._append(line)
def shutdown(self) -> None: self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)"
self._manager.disconnect() self._update_status()
self._session.stop() self._diag_dirty = True
self._refresh_diag()
class MainWindow(QMainWindow): # ---- misc --------------------------------------------------------
def __init__(self): def _save_view(self) -> None:
super().__init__() path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}") if path:
self.resize(1200, 800) with open(path, "w", encoding="utf-8") as fh:
tabs = QTabWidget() fh.write(self.view.toPlainText())
guide = QTextBrowser(openExternalLinks=True)
guide.setHtml(cheatsheet_html()) def _open_folder(self) -> None:
self.serial_tab = SerialTab() self._session.root.mkdir(parents=True, exist_ok=True)
self.flash_tab = FlashTab(self.serial_tab) QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root)))
tabs.addTab(guide, "Quick start")
tabs.addTab(self.serial_tab, "Serial monitor") def shutdown(self) -> None:
tabs.addTab(self.flash_tab, "Flash firmware") self._manager.disconnect()
self.sd_tab = SdTab(self.serial_tab) self._session.stop()
tabs.addTab(self.sd_tab, "SD card")
self.setCentralWidget(tabs)
settings_menu = self.menuBar().addMenu("Settings") class MainWindow(QMainWindow):
settings_menu.addAction("Service system address...", self._edit_service_base) def __init__(self):
super().__init__()
def _edit_service_base(self) -> None: self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}")
st = self.serial_tab self.resize(1200, 800)
text, ok = QInputDialog.getText( tabs = QTabWidget()
self, "Service system address", guide = QTextBrowser(openExternalLinks=True)
"Address of the KOTA service system (the box MAC is added at the end):", guide.setHtml(cheatsheet_html())
text=st._service_base()) self.serial_tab = SerialTab()
if ok and text.strip().startswith(("https://", "http://")): self.flash_tab = FlashTab(self.serial_tab)
st._settings.setValue("service/base", text.strip()) tabs.addTab(guide, "Quick start")
elif ok: tabs.addTab(self.serial_tab, "Serial monitor")
QMessageBox.warning(self, "Service system address", "The address must start with https://") tabs.addTab(self.flash_tab, "Flash firmware")
self.sd_tab = SdTab(self.serial_tab)
def closeEvent(self, event): # noqa: N802 (Qt naming) tabs.addTab(self.sd_tab, "SD card")
self.serial_tab.shutdown() self.setCentralWidget(tabs)
super().closeEvent(event) settings_menu = self.menuBar().addMenu("Settings")
settings_menu.addAction("Service system address...", self._edit_service_base)
def _edit_service_base(self) -> None:
st = self.serial_tab
text, ok = QInputDialog.getText(
self, "Service system address",
"Address of the KOTA service system (the box MAC is added at the end):",
text=st._service_base())
if ok and text.strip().startswith(("https://", "http://")):
st._settings.setValue("service/base", text.strip())
elif ok:
QMessageBox.warning(self, "Service system address", "The address must start with https://")
def closeEvent(self, event): # noqa: N802 (Qt naming)
self.serial_tab.shutdown()
super().closeEvent(event)
+76
View File
@@ -0,0 +1,76 @@
"""Matter pairing QR code on screen, so the technician can scan it from the laptop."""
from __future__ import annotations
import segno
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
from ..matter.payload import SetupPayload
def qr_pixmap(text: str, size: int = 260) -> QPixmap:
"""Black-on-white QR with the quiet zone phones need (4 modules)."""
qr = segno.make(text, error="m")
matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)]
n = len(matrix)
module = max(1, size // n)
img = QImage(n * module, n * module, QImage.Format.Format_RGB32)
img.fill(QColor("white"))
p = QPainter(img)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor("black"))
for y, row in enumerate(matrix):
for x, dark in enumerate(row):
if dark:
p.drawRect(x * module, y * module, module, module)
p.end()
return QPixmap.fromImage(img)
class MatterQrWidget(QWidget):
def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None):
super().__init__(parent)
self.image = QLabel()
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code = QLabel()
self.code.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
f = self.code.font()
f.setPointSize(f.pointSize() + 4)
f.setBold(True)
self.code.setFont(f)
self.note = QLabel(wordWrap=True)
self.note.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.note.setStyleSheet("color:#555")
lay = QVBoxLayout(self)
lay.addWidget(self.image)
lay.addWidget(self.code)
lay.addWidget(self.note)
self._size = size
self.set_payload(payload)
def set_payload(self, payload: SetupPayload) -> None:
self.payload = payload
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
if payload.from_box:
src = "QR and manual code exactly as reported by the box (KOTA: matter line)"
else:
src = f"Discriminator {payload.discriminator} " + (
"(from this box's log)" if payload.from_log else
"(standard code of the v1.0 firmware - connect the box to confirm)")
self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. "
f"{src}. Same as the sticker on the box.")
class MatterQrDialog(QDialog):
def __init__(self, payload: SetupPayload, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Matter pairing code")
lay = QVBoxLayout(self)
lay.addWidget(MatterQrWidget(payload, 320))
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+24 -7
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from PySide6.QtCore import QObject, QSettings, Qt, Signal from PySide6.QtCore import QObject, QSettings, Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QCheckBox, QCheckBox,
QFileDialog, QFileDialog,
QFormLayout, QFormLayout,
@@ -34,6 +33,7 @@ DEFAULT_TEMPLATE = Path(__file__).resolve().parents[4] / "002-SD_card_format" /
class _Bridge(QObject): class _Bridge(QObject):
done = Signal(object) done = Signal(object)
listed = Signal(object)
def _secret_field() -> tuple[QLineEdit, QCheckBox]: def _secret_field() -> tuple[QLineEdit, QCheckBox]:
@@ -52,6 +52,7 @@ class SdTab(QWidget):
self._settings = QSettings("KOTA", "TechnicianTool") self._settings = QSettings("KOTA", "TechnicianTool")
self._cards: list[Disk] = [] self._cards: list[Disk] = []
self._busy = False self._busy = False
self._listing = False
# --- 1. settings # --- 1. settings
sbox = QGroupBox("1. Settings written to the card (ENV.TXT)") sbox = QGroupBox("1. Settings written to the card (ENV.TXT)")
@@ -135,6 +136,7 @@ class SdTab(QWidget):
self._bridge = _Bridge() self._bridge = _Bridge()
self._bridge.done.connect(self._on_done) self._bridge.done.connect(self._on_done)
self._bridge.listed.connect(self._on_listed)
for w in (self.url, self.token, self.ssid, self.password): for w in (self.url, self.token, self.ssid, self.password):
w.textChanged.connect(self._update) w.textChanged.connect(self._update)
self.wifi_box.toggled.connect(self._update) self.wifi_box.toggled.connect(self._update)
@@ -199,13 +201,28 @@ class SdTab(QWidget):
super().showEvent(event) super().showEvent(event)
def refresh_cards(self) -> None: def refresh_cards(self) -> None:
if self._busy: """List cards in the background: the UI never waits for a slow card reader."""
if self._busy or self._listing:
return
self._listing = True
self.others.setText("Looking for SD cards...")
def worker():
try:
result = list_cards()
except Exception as exc: # show it, never leave the tab stuck
result = ([], [], str(exc))
self._bridge.listed.emit(result)
threading.Thread(target=worker, daemon=True, name="sd-list").start()
def _on_listed(self, result) -> None:
self._listing = False
self._cards, others = result[0], result[1]
if len(result) > 2:
self.others.setText(f"Could not list cards: {result[2]}")
self._update()
return return
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
self._cards, others = list_cards()
finally:
QApplication.restoreOverrideCursor()
self.card_list.clear() self.card_list.clear()
for d in self._cards: for d in self._cards:
self.card_list.addItem(QListWidgetItem(d.label)) self.card_list.addItem(QListWidgetItem(d.label))
+3 -2
View File
@@ -1,16 +1,17 @@
[project] [project]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.5.0" version = "0.9.0"
description = "KOTA sensorbox technician diagnostic tool (demo)" description = "KOTA sensorbox technician diagnostic tool (demo)"
requires-python = ">=3.11,<3.14" requires-python = ">=3.11,<3.14"
dependencies = [ dependencies = [
"pyserial>=3.5", "pyserial>=3.5",
"PySide6-Essentials>=6.7", "PySide6-Essentials>=6.7",
"esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands "esptool~=4.8", # 4.x CLI (write_flash/read_flash); v5 renamed commands
"segno>=1.6", # QR code for Matter pairing (pure Python)
] ]
[dependency-groups] [dependency-groups]
dev = ["pytest>=8"] dev = ["pytest>=8", "pyfatfs>=1.0", "setuptools<81"] # pyfatfs (independent FAT32 check) needs pkg_resources
[tool.uv] [tool.uv]
package = false package = false
+4 -2
View File
@@ -2,8 +2,10 @@
# Double-click in Finder, or run: bash run_macos.command # Double-click in Finder, or run: bash run_macos.command
cd "$(dirname "$0")" || exit 1 cd "$(dirname "$0")" || exit 1
# Keep the virtual environment out of the (Google Drive) tool folder. # If Finder refuses to open this file (Google Drive drops the executable bit), run it once as:
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" # bash run_macos.command (or: chmod +x run_macos.command)
# One shared Python environment for every version (update_macos.command keeps it current).
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv"
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
if ! command -v uv >/dev/null 2>&1; then if ! command -v uv >/dev/null 2>&1; then
+2 -2
View File
@@ -2,8 +2,8 @@
setlocal setlocal
cd /d "%~dp0" cd /d "%~dp0"
rem Keep the virtual environment out of the (Google Drive) tool folder. rem One shared Python environment for every version (update_windows.bat keeps it current).
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.5" set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
set "PATH=%USERPROFILE%\.local\bin;%PATH%" set "PATH=%USERPROFILE%\.local\bin;%PATH%"
where uv >nul 2>nul where uv >nul 2>nul
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
ESP-ROM:esp32s3-20210327
rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)
I (1299) app_init: Project name: kota-sensorboard
I (1303) app_init: App version: 1.0.16
I (1307) app_init: Compile time: Sep 28 2026 15:07:57
I (1317) app_init: ELF file SHA256: 4f10989e2...
E (1402) ID: [E-MAIN-04] Device MAC address: A4:CB:8F:C9:46:F8
I (1480) KOTA: led previous_boot status=CONNECTION_ERROR code=1
I (1481) KOTA: led color=purple pattern=blink period=1000ms
I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688
I (1795) KOTA: rtc running=1 time=2026-09-28T15:20:01
I (1850) SD_CARD: SD card mounted successfully
I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=0
I (2300) MAIN: ESP-Matter started successfully
I (2310) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET mode=matter commissioned=1 last_restart=none
I (2315) KOTA: matter fabrics=1 window=closed qr=MT:Y.K9042C00KA0648G00 manual=34970112332
I (2400) KOTA: mic enabled=1 (GPIO3=0, expected 0)
I (4481) KOTA: led color=green pattern=solid
I (5200) WIFI_SERVICE: Got IP address
I (6100) SNTP: Time synchronized: Mon Sep 28 15:20:06 2026
I (8100) KOTA: rtc verify running=1 delta=2s ok=1
I (60010) KOTA: health up=60s status=OK wifi=connected rssi=-58 time_valid=1 sd=ok buffer=0 heap=143220 psram=7.6M last_upload=14s ota=registered
+30 -6
View File
@@ -43,16 +43,20 @@ APP = ["I (1299) app_init: Project name: kota-sensorboard",
# ------------------------------------------------------------------ rules # ------------------------------------------------------------------ rules
def test_rules_load_and_have_no_cycles(): def test_rules_load_and_have_no_cycles():
rules = load_rules() from kota_tech.diag import kota
by_id = {r.id: r for r in rules} graph = {r.id: r.explained_by for r in load_rules()}
for r in rules: for st in ("ERROR", "OUT_OF_RESOURCES", "CONNECTION_ERROR", "RTC_ERROR", "SD_ERROR", "RESETING", "HTTP_SERVER"):
seen, todo = set(), list(r.explained_by) for code in range(7):
if sf := kota.status_finding(st, code):
graph[sf[0]] = sf[5]
for start, explained_by in graph.items():
seen, todo = set(), list(explained_by)
while todo: while todo:
rid = todo.pop() rid = todo.pop()
assert rid != r.id, f"explained_by cycle through {r.id}" assert rid != start, f"explained_by cycle through {start}"
if rid not in seen: if rid not in seen:
seen.add(rid) seen.add(rid)
todo += by_id[rid].explained_by todo += graph.get(rid, ())
# --------------------------------------------------------- real captures # --------------------------------------------------------- real captures
@@ -233,3 +237,23 @@ def test_session_log_format(tmp_path):
for ln in lines: for ln in lines:
e.feed(ln) e.feed(ln)
assert e.boot.tool_reset assert e.boot.tool_reset
def test_live_brownout_loop_is_a_power_problem():
# Real capture (2026-09-28): "E BOD: Brownout detector was triggered" has no
# timestamp and the ROM reports RTC_SW_SYS_RST, not a brown-out reset.
r = replay("live_brownout_loop_A4CB8FC946F8_fw1.0.txt")
top = r.roots[0]
assert top.finding.rule_id == "reboot.brownout" and top.finding.count >= 5
assert "reboot.loop" in {c.rule_id for c in top.consequences}
assert r.verdict.startswith("Problem: Power supply")
@pytest.mark.parametrize("line", [
"E (2893) wifi:sta is connecting, return error",
"E (2892) chip[DL]: esp_wifi_connect() failed: ESP_ERR_WIFI_CONN",
"E (5120) chip[DL]: Failed to get configured network when updating network status: Error ESP32:0x0500300F",
])
def test_harmless_wifi_start_errors_are_not_reported(line):
_, r = run(BOOT + APP + [line])
assert r.roots == [] and "Unrecognised" not in r.to_text()
+103
View File
@@ -0,0 +1,103 @@
"""FAT32/MBR layout: field-level checks against the FAT spec on an in-memory
disk, plus an independent FAT implementation (pyfatfs) mounting the result."""
import struct
import pytest
from kota_tech.sd import fat32
MiB, GiB = 1024**2, 1024**3
def make_disk(size: int, label="KOTA") -> tuple[bytearray, fat32.Fat32Layout]:
lay = fat32.plan(size)
disk = bytearray(b"\xAA" * size) # old garbage everywhere
zeros, writes = fat32.build(lay, label, 0x12345678, 0xCAFEBABE, size)
for off, n in zeros:
disk[off:off + n] = bytes(n)
for off, data in writes:
disk[off:off + len(data)] = data
return disk, lay
@pytest.fixture(scope="module")
def small():
return make_disk(96 * MiB)
def test_mbr_has_one_fat32_lba_partition(small):
disk, lay = small
assert disk[510:512] == b"\x55\xAA"
status, ptype, lba, count = struct.unpack_from("<B3xB3xII", disk, 0x1BE)
assert (status, ptype, lba) == (0, 0x0C, fat32.PART_START // 512)
assert count == lay.part_sectors
assert disk[0x1CE:0x1FE] == bytes(48) # other entries empty
assert struct.unpack_from("<I", disk, 0x1B8)[0] == 0xCAFEBABE
def test_boot_sector_fields(small):
disk, lay = small
b = disk[fat32.PART_START:fat32.PART_START + 512]
bps, spc, rsvd, nfats, root_ent, tot16, media, fat16 = struct.unpack_from("<HBHBHHBH", b, 11)
assert (bps, spc, rsvd, nfats, root_ent, tot16, media, fat16) == (512, lay.sectors_per_cluster, 32, 2, 0, 0, 0xF8, 0)
hidden, tot32, fatsz, _, _, root_clus, fsinfo, bkboot = struct.unpack_from("<IIIHHIHH", b, 28)
assert hidden == fat32.PART_START // 512 and tot32 == lay.part_sectors and fatsz == lay.fat_sectors
assert (root_clus, fsinfo, bkboot) == (2, 1, 6)
assert b[66] == 0x29 and b[71:82] == b"KOTA " and b[82:90] == b"FAT32 " and b[510:] == b"\x55\xAA"
assert disk[fat32.PART_START + 6 * 512:fat32.PART_START + 7 * 512] == b # backup boot sector
def test_fat_is_large_enough_and_clusters_valid(small):
_, lay = small
assert fat32.MIN_CLUSTERS <= lay.clusters
assert lay.fat_sectors * 512 // 4 >= lay.clusters + 2
assert lay.data_start + lay.clusters * lay.sectors_per_cluster <= lay.part_sectors
def test_fsinfo_and_fat_entries(small):
disk, lay = small
info = disk[fat32.PART_START + 512:fat32.PART_START + 1024]
assert struct.unpack_from("<I", info, 0)[0] == 0x41615252
assert struct.unpack_from("<III", info, 484) == (0x61417272, lay.clusters - 1, 3)
assert struct.unpack_from("<I", info, 508)[0] == 0xAA550000
for fat in range(2):
off = fat32.PART_START + (32 + fat * lay.fat_sectors) * 512
assert struct.unpack_from("<III", disk, off) == (0x0FFFFFF8, 0x0FFFFFFF, 0x0FFFFFFF)
assert disk[off + 12:off + lay.fat_sectors * 512] == bytes(lay.fat_sectors * 512 - 12)
def test_root_holds_only_the_label(small):
disk, lay = small
root = fat32.PART_START + lay.data_start * 512
assert disk[root:root + 11] == b"KOTA " and disk[root + 11] == 0x08
assert disk[root + 32:root + lay.sectors_per_cluster * 512] == bytes(lay.sectors_per_cluster * 512 - 32)
@pytest.mark.parametrize("size,spc", [(4 * GiB, 8), (15 * GiB, 16), (31_457_280_000, 32), (64 * GiB, 32)])
def test_real_card_sizes(size, spc):
lay = fat32.plan(size)
assert lay.sectors_per_cluster == spc
assert lay.part_sectors * 512 <= min(size - fat32.PART_START, 32 * GiB)
assert fat32.MIN_CLUSTERS <= lay.clusters < fat32.MAX_CLUSTERS
def test_too_small_card_refused():
with pytest.raises(ValueError):
fat32.plan(20 * MiB)
def test_independent_fat_implementation_mounts_it(small, tmp_path):
pyfat = pytest.importorskip("pyfatfs.PyFat")
disk, lay = small
img = tmp_path / "part.img"
img.write_bytes(bytes(disk[fat32.PART_START:fat32.PART_START + lay.part_sectors * 512]))
fs = pyfat.PyFat()
fs.open(str(img))
assert fs.fat_type == pyfat.PyFat.FAT_TYPE_FAT32
assert fs.bpb_header["BPB_TotSec32"] == lay.part_sectors
fs.close()
from pyfatfs.PyFatFS import PyFatFS
with PyFatFS(str(img)) as ffs: # write + read back a file like ENV.TXT
ffs.writebytes("/ENV.TXT", b"INFLUX_URL=x\n")
assert ffs.readbytes("/ENV.TXT") == b"INFLUX_URL=x\n"
+104 -103
View File
@@ -1,5 +1,6 @@
"""Flashing: image validation on the real official image, plan building, """Flashing: image validation on the real image in firmware/, the folder
and the runner against a fake esptool (no hardware).""" catalogue (compile-date order), plan building, and the runner against a fake
esptool (no hardware)."""
import hashlib import hashlib
import sys import sys
@@ -8,31 +9,39 @@ from pathlib import Path
import pytest import pytest
from kota_tech.flash.image import ImageError, inspect_image, official_images from kota_tech.flash.image import ImageError, app_image_length, folder_images, inspect_image
from kota_tech.flash.plan import Mode, build_plan, check_partition_table from kota_tech.flash.plan import Mode, build_plan, check_partition_table
from kota_tech.flash.runner import FlashRunner from kota_tech.flash.runner import FlashRunner
FW = Path(__file__).resolve().parents[1] / "firmware" / "new_system.bin" FW_DIR = Path(__file__).resolve().parents[1] / "firmware"
FW = sorted(FW_DIR.glob("*.bin"))[0]
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def image(): def image():
imgs = official_images() imgs, problems = folder_images()
assert len(imgs) == 1 assert imgs and problems == []
return imgs[0] return imgs[0]
# ------------------------------------------------------------------ image # ------------------------------------------------------------------ image
def test_official_image_is_the_deployed_v1_build(image): def test_folder_image_is_a_kota_build(image):
assert image.app.project == "kota-sensorboard" assert image.app.project == "kota-sensorboard"
assert image.app.elf_sha.startswith("45fa9d5b9") # same as the live board assert image.kind == "merged"
assert image.app.built_at is not None # compile date is readable
assert image.flash_size == "8MB" assert image.flash_size == "8MB"
assert image.partition("ota_0").offset == 0x40000 assert image.partition("ota_0").offset == 0x40000
assert image.partition("otadata").offset == 0x31000 assert image.partition("otadata").offset == 0x31000
assert image.partition("nvs").offset == 0x10000 assert image.partition("nvs").offset == 0x10000
def test_label_starts_with_compile_date(image):
label = image.label()
assert label.startswith(f"Compiled {image.app.built_at:%Y-%m-%d %H:%M}")
assert image.app.version in label and image.name in label
def test_app_bytes_are_a_complete_app_image(image): def test_app_bytes_are_a_complete_app_image(image):
app = image.app_bytes() app = image.app_bytes()
assert app[0] == 0xE9 assert app[0] == 0xE9
@@ -40,17 +49,6 @@ def test_app_bytes_are_a_complete_app_image(image):
assert len(image.blank_otadata()) == 0x2000 and set(image.blank_otadata()) == {0xFF} assert len(image.blank_otadata()) == 0x2000 and set(image.blank_otadata()) == {0xFF}
def test_modified_image_is_refused(tmp_path):
data = bytearray(FW.read_bytes())
data[-1] ^= 0xFF
bad = tmp_path / "new_system.bin"
bad.write_bytes(data)
manifest = tmp_path / "firmware.toml"
manifest.write_text(f'[[image]]\nfile = "new_system.bin"\nsha256 = "{hashlib.sha256(FW.read_bytes()).hexdigest()}"\n')
with pytest.raises(ImageError, match="SHA-256"):
official_images(manifest)
@pytest.mark.parametrize("patch,msg", [ @pytest.mark.parametrize("patch,msg", [
((0, 0x00), "not an ESP32 firmware"), ((0, 0x00), "not an ESP32 firmware"),
((12, 0x05), "not ESP32-S3"), ((12, 0x05), "not ESP32-S3"),
@@ -64,6 +62,93 @@ def test_wrong_images_are_rejected(tmp_path, patch, msg):
inspect_image(p) inspect_image(p)
def test_integrity_check_on_folder_image(image):
d = image.data
assert app_image_length(d, 0) > 0 # bootloader
assert app_image_length(d, 0x40000) == len(d) - 0x40000 # app ends exactly at file end
@pytest.mark.parametrize("damage,msg", [
(lambda b: b[:len(b) // 2], "truncated"),
(lambda b: b[:0x40000 + 5000] + bytes([b[0x40000 + 5000] ^ 1]) + b[0x40000 + 5001:], "corrupted"),
])
def test_corrupted_or_truncated_images_refused(tmp_path, damage, msg):
p = tmp_path / "broken.bin"
p.write_bytes(damage(FW.read_bytes()))
with pytest.raises(ImageError, match=msg):
inspect_image(p)
# ------------------------------------------------------- firmware folder
def _with_compile_date(data: bytes, date: str, time: str, off: int = 0x40000) -> bytes:
"""Copy of a merged image whose app claims another compile date, with the
segment checksum and appended SHA-256 recomputed (a valid, different build)."""
d = bytearray(data)
desc = off + 0x20
old = bytes(d[desc + 80:desc + 112])
new = time.encode().ljust(16, b"\0") + date.encode().ljust(16, b"\0")
d[desc + 80:desc + 112] = new
end = off + app_image_length(data, off) - 32 # SHA-256 follows the checksum byte
xor = 0
for a, b in zip(old, new):
xor ^= a ^ b
d[end - 1] ^= xor
d[end:end + 32] = hashlib.sha256(bytes(d[off:end])).digest()
return bytes(d)
def test_folder_lists_builds_newest_compile_date_first(tmp_path):
data = FW.read_bytes()
(tmp_path / "a_old.bin").write_bytes(_with_compile_date(data, "Jan 5 2026", "08:00:00"))
(tmp_path / "z_new.bin").write_bytes(_with_compile_date(data, "Dec 24 2026", "18:30:00"))
(tmp_path / "m_mid.bin").write_bytes(_with_compile_date(data, "Jun 15 2026", "12:00:00"))
imgs, problems = folder_images(tmp_path)
assert problems == []
assert [i.name for i in imgs] == ["z_new.bin", "m_mid.bin", "a_old.bin"]
assert imgs[0].label().startswith("Compiled 2026-12-24 18:30")
assert imgs[2].label().startswith("Compiled 2026-01-05 08:00")
def test_folder_reports_bad_files_and_duplicates(tmp_path):
data = FW.read_bytes()
(tmp_path / "good.bin").write_bytes(data)
(tmp_path / "copy.bin").write_bytes(data)
(tmp_path / "notes.bin").write_bytes(b"hello firmware team")
(tmp_path / "readme.txt").write_text("ignored: not a .bin")
imgs, problems = folder_images(tmp_path)
assert len(imgs) == 1
assert any("notes.bin" in p and "not an ESP32 firmware" in p for p in problems)
assert any("same file as" in p for p in problems)
assert not any("readme" in p for p in problems)
def test_app_only_file_uses_the_merged_layout(tmp_path, image):
(tmp_path / "merged.bin").write_bytes(image.data)
(tmp_path / "kota-sensorboard.bin").write_bytes(
_with_compile_date(image.data, "Dec 1 2026", "10:00:00")[0x40000:])
imgs, problems = folder_images(tmp_path)
assert problems == []
app = next(i for i in imgs if i.kind == "app")
assert imgs[0] is app # newer compile date first
assert app.partition("ota_0").offset == 0x40000 and "app only" in app.label()
assert [s.kind for s in build_plan(app, Mode.APP_ONLY, "COM5", tmp_path / "w").steps] == ["read_pt", "write"]
with pytest.raises(ValueError, match="app only"):
build_plan(app, Mode.FULL, "COM5", tmp_path / "w")
def test_app_only_file_without_merged_image_is_not_offered(tmp_path, image):
(tmp_path / "kota-sensorboard.bin").write_bytes(image.app_bytes())
imgs, problems = folder_images(tmp_path)
assert imgs == [] and "no complete (merged) image" in problems[0]
def test_empty_or_missing_folder(tmp_path):
assert folder_images(tmp_path) == ([], [])
imgs, problems = folder_images(tmp_path / "nope")
assert imgs == [] and "not found" in problems[0]
# ------------------------------------------------------------------- plan # ------------------------------------------------------------------- plan
def test_app_only_plan_keeps_nvs(image, tmp_path): def test_app_only_plan_keeps_nvs(image, tmp_path):
@@ -160,87 +245,3 @@ def test_runner_reports_connect_failure_with_hint(image, tmp_path):
res, *_ = _run(plan, cmd) res, *_ = _run(plan, cmd)
assert not res.ok and res.failed_step == 0 assert not res.ok and res.failed_step == 0
assert "BOOT" in res.hint assert "BOOT" in res.hint
# ------------------------------------------------- developer builds (v0.5)
from kota_tech.flash.image import FirmwareLibrary, app_image_length # noqa: E402
def _app_only_file(image, tmp_path, name="kota-sensorboard.bin"):
p = tmp_path / name
p.write_bytes(image.app_bytes()) # what a developer build/ folder contains
return p
def test_integrity_check_on_factory_image(image):
d = image.data
assert app_image_length(d, 0) > 0 # bootloader
assert app_image_length(d, 0x40000) == len(d) - 0x40000 # app ends exactly at file end
@pytest.mark.parametrize("damage,msg", [
(lambda b: b[:len(b) // 2], "truncated"),
(lambda b: b[:0x40000 + 5000] + bytes([b[0x40000 + 5000] ^ 1]) + b[0x40000 + 5001:], "corrupted"),
])
def test_corrupted_or_truncated_images_refused(tmp_path, damage, msg):
p = tmp_path / "broken.bin"
p.write_bytes(damage(FW.read_bytes()))
with pytest.raises(ImageError, match=msg):
inspect_image(p, origin="developer")
def test_import_app_only_developer_build(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
src = _app_only_file(image, tmp_path)
img = lib.import_file(src, reference=image)
assert img.is_developer and img.kind == "app"
assert img.partition("ota_0").offset == 0x40000 # layout from the factory image
assert img.app_bytes() == image.app_bytes()
listed, problems = lib.images(image)
assert [i.sha256 for i in listed] == [img.sha256] and problems == []
assert lib.import_file(src, reference=image).sha256 == img.sha256 # re-import: no duplicate
assert len(lib.images(image)[0]) == 1
def test_app_only_build_cannot_be_flashed_full(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
plan = build_plan(img, Mode.APP_ONLY, "COM5", tmp_path / "w")
assert [s.kind for s in plan.steps] == ["read_pt", "write"]
with pytest.raises(ValueError, match="app only"):
build_plan(img, Mode.FULL, "COM5", tmp_path / "w")
def test_import_merged_developer_build(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
src = tmp_path / "dev_full.bin"
src.write_bytes(image.data)
img = lib.import_file(src, reference=image)
assert img.kind == "merged" and img.is_developer
assert [s.kind for s in build_plan(img, Mode.FULL, "COM5", tmp_path / "w").steps] == ["write"]
def test_library_detects_file_changed_after_import(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
stored = tmp_path / "lib" / img.path.name
data = bytearray(stored.read_bytes())
data[-40] ^= 1
stored.write_bytes(data)
listed, problems = lib.images(image)
assert listed == [] and problems
def test_library_remove(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
img = lib.import_file(_app_only_file(image, tmp_path), reference=image)
lib.remove(img.sha256)
assert lib.images(image) == ([], []) and not img.path.exists()
def test_non_firmware_file_refused(tmp_path, image):
p = tmp_path / "notes.bin"
p.write_bytes(b"hello firmware team")
with pytest.raises(ImageError, match="not an ESP32 firmware"):
FirmwareLibrary(tmp_path / "lib").import_file(p, reference=image)
+238
View File
@@ -0,0 +1,238 @@
"""Firmware 1.0.16+ structured "KOTA:" lines and error IDs.
The lines below are built from the exact format strings in the firmware
source (boot_info.cpp, status.cpp, i2c.cpp, ds1307.cpp, sd_card.cpp,
main.cpp, microphone.cpp, health.cpp); no hardware capture exists yet.
"""
from pathlib import Path
from kota_tech.core.logfile import read_log_file
from kota_tech.core.logline import LineKind, parse_line
from kota_tech.diag.engine import DiagnosticsEngine, Severity
from kota_tech.diag.firmware import same_build
from kota_tech.matter.payload import payload_from_log
from kota_tech.service.report import CONNECTIVITY, HARDWARE, service_status
FIX = Path(__file__).parent / "fixtures"
BANNER = ["ESP-ROM:esp32s3-20210327", "rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)"]
I2C_OK = "I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688"
SD_OK = ("I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB "
"env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=0")
BOOT = ("I (2310) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET "
"mode=matter commissioned=1 last_restart={last}")
def run(lines):
e = DiagnosticsEngine()
for i, text in enumerate(lines):
e.feed(parse_line(text, host_time=i * 0.01))
return e, e.report()
def roots(report):
return {rc.finding.rule_id: rc for rc in report.roots}
# ------------------------------------------------------------- parsing
def test_kota_lines_are_parsed_into_fields():
ln = parse_line(BOOT.format(last="status_watchdog"))
assert ln.kind == LineKind.KOTA and ln.record == "boot" and ln.level == "I"
assert ln.fields["fw"] == "1.0.16" and ln.fields["last_restart"] == "status_watchdog"
st = parse_line("I (5) KOTA: status OK -> CONNECTION_ERROR code=1 reason=influx_rejected")
assert st.words == ("OK", "->", "CONNECTION_ERROR") and st.fields == {"code": "1", "reason": "influx_rejected"}
mic = parse_line("I (6) KOTA: mic enabled=0 (GPIO3=1, expected 0)")
assert mic.fields["enabled"] == "0" and mic.fields["GPIO3"] == "1"
i2c = parse_line("I (7) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307(missing) 0x50=?")
assert i2c.fields["0x68"] == "DS1307(missing)" and i2c.fields["0x50"] == "?"
verify = parse_line("I (8) KOTA: rtc verify running=1 delta=2s ok=1")
assert verify.record == "rtc" and "verify" in verify.words and verify.fields["ok"] == "1"
def test_error_id_is_split_off_but_kept_in_text():
ln = parse_line("E (1200) SD_CARD: [E-SD-03] Failed to initialize the card (ESP_ERR_TIMEOUT).")
assert ln.kind == LineKind.LOG and ln.error_id == "E-SD-03"
assert ln.message.startswith("Failed to initialize") and "[E-SD-03]" in ln.text
assert ln.match_text == "E (1200) SD_CARD: Failed to initialize the card (ESP_ERR_TIMEOUT)."
def test_sd_line_with_secret_key_names_is_not_redacted():
ln = parse_line("I (9) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB "
"env_keys=INFLUX_URL,INFLUX_TOKEN,SSID,PASSWORD buffer_lines=0")
assert ln.fields["env_keys"] == "INFLUX_URL,INFLUX_TOKEN,SSID,PASSWORD"
# -------------------------------------------------------------- engine
def test_healthy_box_from_kota_lines():
e = DiagnosticsEngine()
for ln in read_log_file(FIX / "synthetic_fw1.0.16_healthy.txt"):
e.feed(ln)
r = e.report()
assert r.verdict.startswith("Healthy")
assert (r.led, r.led_pattern, r.led_exact, r.led_meaning) == ("GREEN", "solid", True, "OK")
assert "v1.0.16" in r.firmware.describe() and "4f10989e" in r.firmware.describe()
assert e.mac == "A4:CB:8F:C9:46:F8"
reached = {m for m, _, t in r.milestones if t is not None}
assert {"i2c_ok", "sd_mounted", "env_loaded", "matter_commissioned", "wifi_connected",
"time_synced", "clock_verified", "data_uploaded", "ota_registered"} <= reached
topics = {t: st for t, _, st in r.box}
assert topics["I2C"] == topics["SD card"] == topics["Health"] == "ok"
# The purple state before the tool's reset is shown, but only as information now.
prev = [f for f in r.earlier + [rc.finding for rc in r.roots] if f.rule_id == "boot.previous_status"]
assert not prev or prev[0].severity == Severity.INFO
assert "E-MAIN-04" not in r.to_text() # MAC line is a benign ESP_LOGE
def test_i2c_bus_stuck_is_root_of_blue_led():
_, r = run(BANNER + [
"I (1480) KOTA: led color=blue pattern=pulses n=2",
"I (1790) KOTA: i2c SDA=0 SCL=1 bus=stuck",
"I (1791) KOTA: status OK -> RTC_ERROR code=2 reason=i2c_bus_stuck",
])
rs = roots(r)
assert "kota.i2c_stuck" in rs and rs["kota.i2c_stuck"].finding.severity == Severity.CRITICAL
assert "status.rtc.2" in {c.rule_id for c in rs["kota.i2c_stuck"].consequences}
assert (r.led, r.led_pattern) == ("BLUE", "2 pulses") and "I2C bus stuck" in r.led_meaning
assert service_status(r) == HARDWARE
def test_missing_sensor_named_by_i2c_scan_and_red_pulses():
_, r = run(BANNER + [
"I (1790) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66(missing) 0x76=BME688",
SD_OK,
"E (2100) MAIN: [E-MAIN-16] Failed to initialize SEN66: ESP_ERR_TIMEOUT",
"I (2101) KOTA: status OK -> ERROR code=2 reason=sen66_init_failed",
"I (2102) KOTA: led color=red pattern=pulses n=2",
])
rs = roots(r)
root = rs["kota.sensor_missing"]
assert root.finding.title == "SEN66 does not answer on the I2C bus"
grouped = {c.rule_id for c in root.consequences}
assert {"status.error.2", "sensor.init_failed"} <= grouped
init = next(c for c in root.consequences if c.rule_id == "sensor.init_failed")
assert "E-MAIN-16" in init.error_ids and "SEN66" in init.error_ids["E-MAIN-16"]
assert r.led_meaning == "SEN66 failed"
assert "missing: SEN66" in dict((t, x) for t, x, _ in r.box)["I2C"]
def test_server_rejected_box_is_purple_blinking():
_, r = run(BANNER + [I2C_OK, SD_OK, BOOT.format(last="none"),
"I (70000) KOTA: status OK -> CONNECTION_ERROR code=1 reason=influx_rejected",
"I (70001) KOTA: led color=purple pattern=blink period=1000ms"])
assert r.roots[0].finding.rule_id == "status.conn.1"
assert r.roots[0].finding.severity == Severity.CRITICAL
assert (r.led, r.led_pattern) == ("PURPLE", "blinking (1000 ms)")
assert "server rejected" in r.led_meaning
assert "influx_rejected" in r.led_reason
assert service_status(r) == CONNECTIVITY
def test_status_back_to_ok_resolves_the_status_finding():
_, r = run(BANNER + [I2C_OK, "I (5000) KOTA: status OK -> CONNECTION_ERROR reason=influx_failed",
"I (9000) KOTA: status CONNECTION_ERROR -> OK reason=influx_ok"])
assert "status.conn" not in roots(r)
assert any(f.rule_id == "status.conn" and f.resolved_by == "status -> OK" for f in r.resolved)
def test_restart_reason_from_the_next_boot_line():
_, r = run(BANNER + [I2C_OK, BOOT.format(last="none"),
"I (5000) KOTA: status OK -> ERROR code=3 reason=as7341_failed",
"W (155000) KOTA: restart reason=status_watchdog",
"I (155001) KOTA: led restart_flash color=red",
"ESP-ROM:esp32s3-20210327", "rst:0xc (RTC_SW_CPU_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT)",
"I (1480) KOTA: led previous_boot status=ERROR code=3",
I2C_OK, BOOT.format(last="status_watchdog")])
ids = {f.rule_id: f for rc in r.roots for f in [rc.finding, *rc.consequences]}
f = ids["reboot.status_watchdog"]
assert "150 s" in f.title and "state ERROR code=3" in f.meaning
def test_brownout_restart_recorded_by_firmware():
_, r = run(BANNER + [I2C_OK, BOOT.format(last="none"), "I (90000) KOTA: health up=90s status=OK "
"wifi=connected rssi=-60 time_valid=1 sd=ok buffer=0 heap=150000 psram=7.6M "
"last_upload=10s ota=registered",
"ESP-ROM:esp32s3-20210327", "rst:0xf (RTCWDT_BROWN_OUT_RESET),boot:0x8",
I2C_OK, BOOT.format(last="brownout")])
assert r.roots[0].finding.rule_id == "reboot.brownout"
def test_uncaptured_error_id_uses_the_csv_action():
_, r = run(BANNER + [I2C_OK, "E (3000) INFLUX_BUFFER: [E-INF-13] Failed to open buffer file for appending"])
f = next(f for rc in r.roots for f in [rc.finding, *rc.consequences] if "E-INF-13" in f.error_ids)
# A rule already knows this message, so the rule finding carries the code.
assert f.rule_id == "sd.write_fail"
_, r = run(BANNER + [I2C_OK, "E (3000) SPI: [E-SPI-01] Failed to take SPI mutex"])
f = roots(r)["eid.SPI"].finding
assert f.title == "SPI bus: Failed to take SPI mutex"
assert "firmware team" in f.actions[0] and f.error_ids == {"E-SPI-01": f.actions[0]}
def test_legacy_rules_still_match_lines_with_error_ids():
_, r = run(BANNER + ["E (1200) MAIN: [E-MAIN-13] Failed to initialize RTC: ESP_ERR_TIMEOUT"])
assert "rtc.init_failed" in roots(r)
def test_health_line_gives_status_when_connected_mid_run():
e, r = run(["I (600000) KOTA: health up=600s status=CONNECTION_ERROR wifi=disconnected rssi=0 "
"time_valid=1 sd=ok buffer=120 heap=150000 psram=7.6M last_upload=never ota=registered",
"I (660000) KOTA: health up=660s status=CONNECTION_ERROR wifi=disconnected rssi=0 "
"time_valid=1 sd=ok buffer=130 heap=150000 psram=7.6M last_upload=never ota=registered"])
assert (r.led, r.led_pattern) == ("PURPLE", "solid") and not r.led_exact
rs = roots(r)
assert "kota.wifi_down" in rs
assert "status.conn" in {c.rule_id for c in rs["kota.wifi_down"].consequences}
assert r.mid_run
def test_sd_and_rtc_self_report():
_, r = run(BANNER + [
"I (1795) KOTA: rtc running=0 time=2000-01-01T00:00:00",
"I (1990) KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL buffer_lines=0",
"I (8100) KOTA: rtc verify running=0 delta=0s ok=0",
])
ids = {f.key for rc in r.roots for f in [rc.finding, *rc.consequences]}
assert {"influx.env_missing:INFLUX_TOKEN", "kota.rtc_stopped", "kota.rtc_not_ticking"} <= ids
_, r = run(BANNER + ["I (1990) KOTA: sd mounted=0 err=ESP_ERR_TIMEOUT",
"I (1991) KOTA: status OK -> SD_ERROR reason=sd_mount_failed"])
rs = roots(r)
assert "kota.sd_unmounted" in rs and "ESP_ERR_TIMEOUT" in rs["kota.sd_unmounted"].finding.title
assert "status.sd" in {c.rule_id for c in rs["kota.sd_unmounted"].consequences}
def test_matter_and_mic_lines():
e, r = run(BANNER + ["I (2315) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332",
"I (2400) KOTA: mic enabled=0 (GPIO3=1, expected 0)",
"I (4481) KOTA: led color=cyan pattern=breathe period=3000ms"])
rs = roots(r)
assert "matter.not_commissioned" in rs and "mic.disabled" in rs
assert (r.led, r.led_meaning) == ("CYAN", "waiting to be paired in Matter")
p = payload_from_log([], e.matter_qr, e.matter_manual)
assert p.qr_text() == "MT:Y.K9042C00KA0648G00" and p.manual_code_pretty() == "3497-011-2332"
_, r = run(BANNER + ["I (2315) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332",
"I (90000) KOTA: matter fabrics=1 window=closed qr=MT:Y.K9042C00KA0648G00 manual=34970112332"])
assert "matter.not_commissioned" not in roots(r)
def test_build_prefixes_of_different_length_match():
full = "4f10989e2b40c1d29fb3e8f8faaa50e5555997909c6d6b1db9d4e1e4d04555dc"
assert same_build("4f10989e", full) and same_build(full, "4f10989e2")
assert not same_build("4f10989f", full) and not same_build(None, full)
def test_live_fw1016_uncommissioned_box():
# Real capture (2026-09-28) after flashing 1.0.16 App-only: not paired in Matter.
e = DiagnosticsEngine()
for ln in read_log_file(FIX / "live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt"):
e.feed(ln)
r = e.report()
assert r.firmware.version == "1.0.16" and same_build(r.firmware.elf_sha, "4f10989e")
assert (r.led, r.led_pattern, r.led_exact) == ("CYAN", "breathing", True)
rs = roots(r)
assert r.roots[0].finding.rule_id == "matter.not_commissioned"
grouped = {c.rule_id for c in rs["matter.not_commissioned"].consequences}
assert {"kota.wifi_down", "net.dns_fail", "status.conn", "kota.buffer_backlog"} <= grouped
assert "rtc.clock_not_running" in {c.rule_id for c in rs["kota.rtc_stopped"].consequences}
assert e.matter_qr == "MT:Y.K9042C00KA0648G00"
assert not any(f.rule_id.startswith("other.") for f in e.findings.values())
+42
View File
@@ -0,0 +1,42 @@
"""Matter onboarding payload, checked against the real sticker of the bench box
(QR decoded from matter_commissioning_qr.png: MT:Y.K9042C00KA0648G00)."""
from pathlib import Path
import segno
from kota_tech.core.logfile import read_log_file
from kota_tech.matter.payload import SetupPayload, payload_from_log, verhoeff_check
STICKER_QR = "MT:Y.K9042C00KA0648G00"
STICKER_MANUAL = "34970112332" # printed as 3497-011-2332
def test_default_payload_matches_sticker():
p = SetupPayload()
assert p.qr_text() == STICKER_QR
assert p.manual_code() == STICKER_MANUAL
assert p.manual_code_pretty() == "3497-011-2332"
def test_payload_from_real_boot_log():
lines = [ln.text for ln in read_log_file(Path(__file__).parent / "fixtures" / "serial_log_20260917_162548.txt")]
p = payload_from_log(lines)
assert p.from_log and (p.vendor_id, p.product_id, p.discriminator) == (0xFFF1, 0x8000, 3840)
assert p.qr_text() == STICKER_QR
def test_other_discriminator_changes_both_codes():
p = SetupPayload(discriminator=1234)
assert p.qr_text() != STICKER_QR and p.manual_code() != STICKER_MANUAL
assert p.qr_text().startswith("MT:") and len(p.qr_text()) == 3 + 19
def test_verhoeff():
assert verhoeff_check("3497011233") == 2
assert verhoeff_check("236") == 3 # textbook example
def test_qr_renders():
qr = segno.make(STICKER_QR, error="m")
assert qr.symbol_size(border=0)[0] >= 21
+12
View File
@@ -12,3 +12,15 @@ def test_other_ports_not_sensorbox():
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None) bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
assert not bt.is_sensorbox and bt.mac is None assert not bt.is_sensorbox and bt.mac is None
assert bt.label == "COM3 - Standard Serial over Bluetooth link" assert bt.label == "COM3 - Standard Serial over Bluetooth link"
def test_macos_lists_only_callout_devices(monkeypatch):
from types import SimpleNamespace
from kota_tech.serialio import ports
fake = [SimpleNamespace(device=d, description="USB JTAG/serial debug unit", vid=0x303A, pid=0x1001,
serial_number="A4:CB:8F:C9:46:F8")
for d in ("/dev/tty.usbmodem1101", "/dev/cu.usbmodem1101")]
monkeypatch.setattr(ports.sys, "platform", "darwin")
monkeypatch.setattr(ports.list_ports, "comports", lambda: fake)
assert [p.device for p in ports.list_serial_ports()] == ["/dev/cu.usbmodem1101"]
+34 -10
View File
@@ -5,9 +5,12 @@ import plistlib
import pytest import pytest
from kota_tech.sd.disks import MAX_CARD_BYTES, Disk, parse_macos, parse_windows import sys
from pathlib import Path
from kota_tech.sd.disks import MAX_CARD_BYTES, Disk, Volume, parse_macos, parse_windows
from kota_tech.sd.envfile import EnvConfig, parse_env from kota_tech.sd.envfile import EnvConfig, parse_env
from kota_tech.sd.provision import check_card, windows_format_script, write_env from kota_tech.sd.provision import check_card, win_format_command, write_env
URL = "https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota" URL = "https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota"
TOKEN = "T" * 86 + "==" # shape of an InfluxDB token, not a real one TOKEN = "T" * 86 + "==" # shape of an InfluxDB token, not a real one
@@ -108,15 +111,25 @@ def test_parse_macos_excludes_system_disk():
# -------------------------------------------------------- format script # -------------------------------------------------------- format script
def test_windows_script_rechecks_disk_before_erasing(tmp_path): def test_elevated_helper_command(tmp_path):
disk = Disk("5", "SDXC Card", 63_864_569_856, "SD") disk = Disk("5", "SDXC Card", 63_864_569_856, "SD")
ps = windows_format_script(disk, tmp_path / "r.json") prog, params, cwd = win_format_command(disk, tmp_path / "r.json")
erase = ps.index("Clear-Disk") assert params.startswith("-m kota_tech.sd.win_format --disk 5 --size 63864569856 --label KOTA")
for guard in ("IsSystem -or $d.IsBoot", "'MMC','SD','USB'", "-ne 63864569856"): assert (Path(cwd) / "kota_tech").is_dir() # helper is importable from there
assert guard in ps and ps.index(guard) < erase
assert "-PartitionStyle MBR" in ps and "-FileSystem FAT32" in ps
assert str(32 * 1024**3) in ps # FAT32 partition capped at 32 GB @pytest.mark.skipif(sys.platform != "win32", reason="Windows formatter")
assert "NewFileSystemLabel 'KOTA'" in ps @pytest.mark.parametrize("disks,msg", [
([], "not a USB/SD/MMC"), # disk vanished / internal
([Disk("5", "SD", 1_000, "SD")], "changed since it was selected"), # swapped card
([Disk("5", "SD", 31_457_280_000, "SD", [Volume("C:\\", "NTFS", "")])], "Windows drive"),
])
def test_formatter_guard_refuses_before_writing(monkeypatch, disks, msg):
from kota_tech.sd import win_format
monkeypatch.setattr(win_format.win_disks, "list_disks", lambda buses: disks)
monkeypatch.setattr(win_format, "RawDisk", lambda *a: pytest.fail("must not open the disk for writing"))
with pytest.raises(win_format.FormatError, match=msg):
win_format.format_card(5, 31_457_280_000, "KOTA", {})
def test_large_disks_are_not_cards(): def test_large_disks_are_not_cards():
@@ -157,3 +170,14 @@ def test_elevated_result_folder_is_not_owner_only(tmp_path, monkeypatch):
assert work.parent == tmp_path / "tmp" and work.is_dir() assert work.parent == tmp_path / "tmp" and work.is_dir()
(work / "result.json").write_text('{"ok": true}', encoding="utf-8") (work / "result.json").write_text('{"ok": true}', encoding="utf-8")
assert (work / "result.json").read_text(encoding="utf-8") assert (work / "result.json").read_text(encoding="utf-8")
@pytest.mark.parametrize("fs", ["FAT32", "DOS_FAT_32", "Windows_FAT_32", "MS-DOS FAT32"])
def test_fat32_names_from_windows_and_macos_are_accepted(tmp_path, fs):
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
assert not any("File system" in p for p in check_card(str(tmp_path), fs).problems)
def test_exfat_is_flagged(tmp_path):
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
assert any("File system" in p for p in check_card(str(tmp_path), "ExFAT").problems)
+17 -1
View File
@@ -33,7 +33,7 @@ def test_bench_box_is_a_connectivity_problem():
assert service_status(e.report()) == CONNECTIVITY assert service_status(e.report()) == CONNECTIVITY
note = service_note(e.report()) note = service_note(e.report())
assert note.startswith("[KOTA Technician Tool") assert note.startswith("[KOTA Technician Tool")
assert "not commissioned in Matter" in note and "-> No Internet" in note assert "not commissioned in Matter" in note and "↳ [Error] No Internet" in note
assert "Firmware: v1.0" in note assert "Firmware: v1.0" in note
@@ -89,3 +89,19 @@ def test_invalid_mac_rejected(bad):
def test_normalize_mac(): def test_normalize_mac():
assert normalize_mac("a4:cb:8f:c9:46:f8") == "A4CB8FC946F8" assert normalize_mac("a4:cb:8f:c9:46:f8") == "A4CB8FC946F8"
def test_note_mirrors_the_diagnosis_panel():
from pathlib import Path
from kota_tech.core.logfile import read_log_file
e = DiagnosticsEngine()
for ln in read_log_file(Path(__file__).parent / "fixtures" / "live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt"):
e.feed(ln)
note = service_note(e.report())
assert len(note) <= 5000
for part in ("Status LED should be: Cyan, breathing", "Box self-report (KOTA lines):",
"✔ I2C: found: AS7341, DS1307, SEN66, BME688", "Start-up progress (current boot):",
"✔ SD card mounted (1.7 s)", "○ Connected to Wi-Fi", "What to do:",
"Seen 12x, first at 21.6 s after start, last at 126.8 s",
"↳ [Error] No Internet: server names cannot be resolved"):
assert part in note, part
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# KOTA Sensorbox Technician Tool - update to the newest released version.
# Checks Gitea for the newest release tag (vX.Y) and downloads only what changed (git).
# Works in a git clone and in a folder unpacked from a release zip (it is connected on first run).
# If Finder refuses to open this file, run it once as: bash update_macos.command
cd "$(dirname "$0")" || exit 1
REPO_URL="${KOTA_REPO_URL:-https://gitea.kota-app.com/kkallas/kota-technician-tool.git}"
export UV_PROJECT_ENVIRONMENT="${UV_PROJECT_ENVIRONMENT:-$HOME/Library/Caches/KotaTechnicianTool/venv}"
export PATH="$HOME/.local/bin:$PATH"
finish() {
[ -z "$KOTA_NO_PAUSE" ] && [ -t 0 ] && read -r -p "Press Enter to close..." _
exit "$1"
}
fail() { echo; echo "[!] $*"; echo "[!] Update did not finish."; finish 1; }
# ---- git (macOS: the Command Line Tools installer offers it) --------------
if ! git --version >/dev/null 2>&1; then
if [ "$(uname)" = Darwin ]; then
xcode-select --install >/dev/null 2>&1
fail "Git is being installed (Apple Command Line Tools). Run this again when the installer has finished."
fi
fail "Git is not installed."
fi
# ---- connect a folder unpacked from a zip ----------------------------------
if [ ! -d .git ]; then
echo "Connecting this folder to $REPO_URL ..."
git init -q && git remote add origin "$REPO_URL" || fail "Cannot set up git in this folder."
fi
# A developer copy follows a branch; it is updated with "git pull", not here.
if git rev-parse -q --verify HEAD >/dev/null 2>&1 && branch=$(git symbolic-ref -q --short HEAD); then
echo "This is a developer copy on branch $branch. Update it with: git pull"
finish 0
fi
echo "Checking for a new version..."
git fetch --quiet --force --tags origin ||
fail "Cannot reach $REPO_URL. Check the Internet connection and your Gitea login, then try again."
latest=$(git tag -l 'v*' --sort=-v:refname | head -n 1)
[ -n "$latest" ] || fail "No released version found on the server."
current=$(git describe --tags --exact-match HEAD 2>/dev/null)
if [ "$current" = "$latest" ]; then
echo "You already have the newest version $latest."
else
if [ -n "$current" ]; then
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "Some tool files in this folder were changed by hand. They will be replaced by $latest."
read -r -p "Continue? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || fail "Cancelled."
fi
echo "Updating $current -> $latest. Changes:"
git --no-pager log --oneline --no-decorate "$current..$latest"
else
echo "Installing $latest..."
fi
git -c advice.detachedHead=false checkout -q -f "$latest" || fail "Checkout of $latest failed."
echo "Now on $latest."
fi
# ---- Python libraries (only what changed) ----------------------------------
if ! command -v uv >/dev/null 2>&1; then
echo "Installing uv (Python package runner, current user only)..."
curl -LsSf https://astral.sh/uv/install.sh | sh || fail "uv installation failed. Ask the firmware team for help."
fi
echo "Checking Python libraries..."
uv sync --frozen --no-dev --python 3.12 --quiet || fail "Installing Python libraries failed."
echo
echo "Done. Start the tool with run_macos.command."
finish 0
+107
View File
@@ -0,0 +1,107 @@
@echo off
rem KOTA Sensorbox Technician Tool - update to the newest released version.
rem Checks Gitea for the newest release tag (vX.Y) and downloads only what changed (git).
rem Works in a git clone and in a folder unpacked from a release zip (it is connected on first run).
setlocal EnableDelayedExpansion
cd /d "%~dp0"
title KOTA Technician Tool - update
if not defined KOTA_REPO_URL set "KOTA_REPO_URL=https://gitea.kota-app.com/kkallas/kota-technician-tool.git"
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv"
set "PATH=%USERPROFILE%\.local\bin;%ProgramFiles%\Git\cmd;%PATH%"
rem ---- git ----------------------------------------------------------------
where git >nul 2>nul
if errorlevel 1 (
echo Git is not installed. It is needed to download updates.
choice /m "Install Git for Windows now"
if errorlevel 2 goto :fail
winget install --id Git.Git -e --source winget --accept-package-agreements
where git >nul 2>nul
if errorlevel 1 (
echo ERROR: Git installation failed. Install it from https://git-scm.com and run this again.
goto :fail
)
)
rem ---- connect a folder unpacked from a zip ---------------------------------
if not exist ".git" (
echo Connecting this folder to %KOTA_REPO_URL% ...
git init -q || goto :fail
git remote add origin "%KOTA_REPO_URL%" || goto :fail
)
rem A developer copy follows a branch; it is updated with "git pull", not here.
git rev-parse -q --verify HEAD >nul 2>nul
if not errorlevel 1 (
for /f "delims=" %%b in ('git symbolic-ref -q --short HEAD 2^>nul') do (
echo This is a developer copy on branch %%b. Update it with: git pull
goto :done
)
)
echo Checking for a new version...
git fetch --quiet --force --tags origin
if errorlevel 1 (
echo ERROR: Cannot reach %KOTA_REPO_URL%.
echo Check the Internet connection and your Gitea login, then try again.
goto :fail
)
set "LATEST="
for /f "delims=" %%t in ('git tag -l "v*" --sort^=-v:refname') do if not defined LATEST set "LATEST=%%t"
if not defined LATEST (
echo ERROR: No released version found on the server.
goto :fail
)
set "CURRENT="
for /f "delims=" %%t in ('git describe --tags --exact-match HEAD 2^>nul') do set "CURRENT=%%t"
if "%CURRENT%"=="%LATEST%" (
echo You already have the newest version %LATEST%.
goto :deps
)
if defined CURRENT (
for /f "delims=" %%s in ('git status --porcelain --untracked-files^=no') do set "DIRTY=1"
if defined DIRTY (
echo Some tool files in this folder were changed by hand. They will be replaced by %LATEST%.
choice /m "Continue"
if errorlevel 2 goto :fail
)
echo Updating %CURRENT% -^> %LATEST%. Changes:
git --no-pager log --oneline --no-decorate "%CURRENT%..%LATEST%"
) else (
echo Installing %LATEST%...
)
git -c advice.detachedHead=false checkout -q -f "%LATEST%"
if errorlevel 1 goto :fail
echo Now on %LATEST%.
rem ---- Python libraries (only what changed) ----------------------------------
:deps
where uv >nul 2>nul
if errorlevel 1 (
echo Installing uv ^(Python package runner, current user only^)...
powershell -NoProfile -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
)
where uv >nul 2>nul
if errorlevel 1 (
echo ERROR: uv installation failed. Ask the firmware team for help.
goto :fail
)
echo Checking Python libraries...
uv sync --frozen --no-dev --python 3.12 --quiet
if errorlevel 1 goto :fail
echo.
echo Done. Start the tool with run_windows.bat.
:done
if not defined KOTA_NO_PAUSE pause
exit /b 0
:fail
echo.
echo ERROR: Update did not finish.
if not defined KOTA_NO_PAUSE pause
exit /b 1
Generated
+63 -2
View File
@@ -6,6 +6,15 @@ resolution-markers = [
"platform_machine != 'x86_64' or sys_platform != 'darwin'", "platform_machine != 'x86_64' or sys_platform != 'darwin'",
] ]
[[package]]
name = "appdirs"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
]
[[package]] [[package]]
name = "argcomplete" name = "argcomplete"
version = "3.7.2" version = "3.7.2"
@@ -207,6 +216,20 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/84/19/f68b27fd43564d69277e956d13b8c2b352e7f1af4fb5d6e5c43e5419e61c/esptool-4.12.0.tar.gz", hash = "sha256:d797f6529d582173508baf0572542ddadf53aae8c92c84ea9f420f242464103c", size = 463840, upload-time = "2026-07-14T08:36:51.891Z" } sdist = { url = "https://files.pythonhosted.org/packages/84/19/f68b27fd43564d69277e956d13b8c2b352e7f1af4fb5d6e5c43e5419e61c/esptool-4.12.0.tar.gz", hash = "sha256:d797f6529d582173508baf0572542ddadf53aae8c92c84ea9f420f242464103c", size = 463840, upload-time = "2026-07-14T08:36:51.891Z" }
[[package]]
name = "fs"
version = "2.4.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "appdirs" },
{ name = "setuptools" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261, upload-time = "2022-05-02T09:25:52.363Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@@ -227,17 +250,20 @@ wheels = [
[[package]] [[package]]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.4.0" version = "0.9.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "esptool" }, { name = "esptool" },
{ name = "pyserial" }, { name = "pyserial" },
{ name = "pyside6-essentials" }, { name = "pyside6-essentials" },
{ name = "segno" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pyfatfs" },
{ name = "pytest" }, { name = "pytest" },
{ name = "setuptools" },
] ]
[package.metadata] [package.metadata]
@@ -245,10 +271,15 @@ requires-dist = [
{ name = "esptool", specifier = "~=4.8" }, { name = "esptool", specifier = "~=4.8" },
{ name = "pyserial", specifier = ">=3.5" }, { name = "pyserial", specifier = ">=3.5" },
{ name = "pyside6-essentials", specifier = ">=6.7" }, { name = "pyside6-essentials", specifier = ">=6.7" },
{ name = "segno", specifier = ">=1.6" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }] dev = [
{ name = "pyfatfs", specifier = ">=1.0" },
{ name = "pytest", specifier = ">=8" },
{ name = "setuptools", specifier = "<81" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
@@ -277,6 +308,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
] ]
[[package]]
name = "pyfatfs"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/3f/d08f1dbc44a7eef9c7fb355b83423fbd15bb3e487c250479a2c179cb39bf/pyfatfs-1.1.0.tar.gz", hash = "sha256:9725ccd0a4da1c09c27358abbf10f08c043ac84210af576803e087f51a2b30e0", size = 55459, upload-time = "2024-03-03T20:50:43.227Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/de/feb531b25eb2315aa73a7dba335c253ee0075ff7f55b6fe742fb6d492ade/pyfatfs-1.1.0-py3-none-any.whl", hash = "sha256:7401fd39d9e92f531b2abea5b5d1d2c19d5461f4ba9ce8819b1ba35aea73c65d", size = 37932, upload-time = "2024-03-03T20:50:41.426Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.21.0" version = "2.21.0"
@@ -372,6 +415,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" }, { url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" },
] ]
[[package]]
name = "segno"
version = "1.6.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/2e/b396f750c53f570055bf5a9fc1ace09bed2dff013c73b7afec5702a581ba/segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3", size = 1628586, upload-time = "2025-03-12T22:12:53.324Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" },
]
[[package]]
name = "setuptools"
version = "80.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" },
]
[[package]] [[package]]
name = "shiboken6" name = "shiboken6"
version = "6.11.2" version = "6.11.2"