2 Commits
Author SHA1 Message Date
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
35 changed files with 5031 additions and 472 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# KOTA Sensorbox Technician Tool v0.8
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.
## Uruchomienie
**Windows:** dwuklik `run_windows.bat`.
**macOS:** dwuklik `run_macos.command`.
Jeśli Finder odpowie, że pliku nie można uruchomić (Google Drive gubi uprawnienie „wykonywalny”),
otwórz Terminal i jednorazowo wpisz `bash ` (ze spacją), przeciągnij plik `run_macos.command` do okna i naciśnij Enter.
Na stałe naprawia to: `chmod +x run_macos.command`.
Przy pierwszym uruchomieniu skrypt proponuje instalację `uv` (menedżer Pythona, instaluje się tylko dla bieżącego
użytkownika) i pobiera biblioteki. Potrzebny jest internet, trwa to 1–2 minuty. Kolejne starty są natychmiastowe.
Środowisko jest poza folderem Google Drive:
`%LOCALAPPDATA%\KotaTechnicianTool\venv-0.8` (Windows), `~/Library/Caches/KotaTechnicianTool/venv-0.8` (macOS).
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**.
## Testy (dla zespołu)
```
uv run --frozen pytest -q
```
Zmiany: `docs/CHANGES-v0.7.md`, `docs/CHANGES-v0.8.md`.
+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.
+21 -16
View File
@@ -1,9 +1,10 @@
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: Rules:
- Python only via uv or conda/mamba. Never use bare/system python or pip. - Python only via uv or conda/mamba. Never use bare/system python or pip.
- Do not build, modify or flash firmware other than the bundled firmware/new_system.bin. - Do not build or modify firmware. Flash only files from the tool's firmware/ folder.
- Do not push anything to Git or GitLab. This is a local demo. - Do not push anything to Git or GitLab. This is a local demo.
- Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files. - Ask me before any destructive action: formatting an SD card, Full-mode flashing, or deleting files.
Before formatting, show me the exact disk (identifier, name, size, current files). Before formatting, show me the exact disk (identifier, name, size, current files).
@@ -11,20 +12,24 @@ Rules:
Tests, in order. Report pass/fail with evidence for each: 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. 1. Launch: `bash run_macos.command` (it offers to install uv). The app window opens; note any warnings.
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.5" uv run --frozen pytest -q` Also check whether a double-click in Finder works (Google Drive may drop the executable bit).
2. Tests: `UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8" uv run --frozen pytest -q`
(expect 172 passed).
3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)" 3. Serial: plug in the sensorbox (native USB). The port list shows a /dev/cu.usbmodem* "Sensorbox (native USB)"
entry with a MAC. Connect, press "Reset & capture": the boot log starts at "ESP-ROM:esp32s3" and the entry with a MAC (no /dev/tty.* duplicate). Connect, press "Reset & capture": the log starts at
Diagnosis panel identifies firmware v1.0 (build 45fa9d5b9). Check whether the port disappears after the "ESP-ROM:esp32s3" and the Diagnosis panel shows firmware v1.0.16 (build 4f10989e), the "Box self-report"
reset and whether the tool reconnects. section and the LED with its pattern. Check whether the port disappears after the reset and whether the
4. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows. tool reconnects. Wait 2 minutes: a "KOTA: health" line appears every 60 s.
5. Flash (App only, factory image; the box already runs it): the tool must enter flashing mode by itself, 4. Show filter "KOTA diagnostics + errors" shows only KOTA lines and errors.
write, restart the box and report "Success ... build 45fa9d5b9". Capture the esptool output. 5. Guided troubleshooting: run "Quick check" and answer truthfully with what I tell you the LED shows.
6. SD card tab: the settings load from ../../002-SD_card_format/ENV.txt. The card reader and card are listed 6. Flash (App only, the newest file in firmware/, chosen by its compile date): the tool must enter flashing mode
(internal disks must NOT be listed). Run "Check card", then, after my OK, "Format card and write ENV.TXT". by itself, write, restart the box and report "Success ... build 4f10989e". Capture the esptool output.
It must use `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT byte-identical 7. SD card tab: the card reader and card are listed (internal disks must NOT be listed). "Check card" must NOT
to the template, and eject the card. report a wrong file system for a FAT32 card. After my OK, "Format card and write ENV.TXT" must use
7. "Service report..." opens the browser at https://service.kota-app.com/<MAC>#kota-report=... (it is fine `diskutil eraseDisk FAT32 KOTA MBRFormat` on the correct disk, write ENV.TXT and eject the card.
if the page itself cannot prefill yet). 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 At the end, write a short report: macOS version, Mac model/architecture, each test with result, errors, and
suggested fixes (file:line). suggested fixes (file:line).
+1 -1
View File
@@ -1,3 +1,3 @@
"""KOTA sensorbox technician tool.""" """KOTA sensorbox technician tool."""
__version__ = "0.6.0" __version__ = "0.8.0"
+57 -1
View File
@@ -4,6 +4,12 @@ ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally
wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset 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"
tag ("I (812) KOTA: boot fw=1.0.16 build=4f10989e ... last_restart=none")
and prefixes every ESP_LOGE message with a stable error ID ("[E-SD-03] ").
Both are parsed here so the diagnostics engine reads fields directly
instead of matching message text.
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,6 +27,9 @@ ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)". # "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:") BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)") RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
# "[E-SD-03] Failed to initialize the card ..." (firmware error_ids.csv).
ERROR_ID_RE = re.compile(r"^\[(E-[A-Z0-9]+-\d{2,})\] ?")
KOTA_TAG = "KOTA"
PANIC_RE = re.compile( PANIC_RE = re.compile(
r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|" r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|"
r"^Backtrace:|Rebooting\.\.\.$" r"^Backtrace:|Rebooting\.\.\.$"
@@ -32,6 +41,7 @@ class LineKind(str, Enum):
BOOT = "boot" # ROM boot banner (start of a new boot) BOOT = "boot" # ROM boot banner (start of a new boot)
RESET = "reset" # ROM reset-reason line RESET = "reset" # ROM reset-reason line
PANIC = "panic" # crash / abort output PANIC = "panic" # crash / abort output
KOTA = "kota" # structured "KOTA: <record> key=value ..." line
RAW = "raw" # anything else (task list rows, line protocol, ...) RAW = "raw" # anything else (task list rows, line protocol, ...)
HOST = "host" # event generated by this tool (connect, reset, ...) HOST = "host" # event generated by this tool (connect, reset, ...)
@@ -50,6 +60,44 @@ class LogLine:
tag: str | None = None tag: str | None = None
message: str | None = None message: str | None = None
reset_reason: str | None = None reset_reason: str | None = None
error_id: str | None = None # "E-SD-03" when the message carries one
record: str | None = None # KOTA lines: "boot", "i2c", "health", ...
fields: dict[str, str] = field(default_factory=dict) # KOTA key=value pairs
words: tuple[str, ...] = () # KOTA bare words ("verify", "OK", "->", ...)
@property
def match_text(self) -> str:
"""Text for message rules: the error-ID prefix removed, so rules
written for older firmware keep matching."""
if self.error_id and self.message is not None and self.tag:
return self.text.replace(f"[{self.error_id}] ", "", 1)
return self.text
def parse_kota(body: str) -> tuple[str, dict[str, str], tuple[str, ...]]:
"""'rtc verify running=1 delta=2s ok=1' -> ('rtc', {...}, ('verify',)).
Values never contain spaces. The mic line has a parenthesised aside
("(GPIO3=1, expected 1)"): brackets and commas are stripped from keys
and values, and the aside's words are kept as bare words.
"""
tokens = body.split()
if not tokens:
return "", {}, ()
fields: dict[str, str] = {}
words: list[str] = []
for tok in tokens[1:]:
key, eq, value = tok.partition("=")
if eq and key.strip("(,"):
key = key.lstrip("(")
# "0x68=DS1307(missing)" keeps its bracket; "(GPIO3=1," loses it.
value = value.rstrip(",")
if tok.startswith("(") or value.endswith(")") and "(" not in value:
value = value.rstrip(")")
fields[key] = value
else:
words.append(tok.strip("(),"))
return tokens[0], fields, tuple(w for w in words if w)
def clean(raw: str) -> str: def clean(raw: str) -> str:
@@ -61,7 +109,15 @@ def parse_line(raw: str, host_time: float | None = None) -> LogLine:
t = time.time() if host_time is None else host_time t = time.time() if host_time is None else host_time
if m := ESP_LOG_RE.match(text): if m := ESP_LOG_RE.match(text):
level, ms, tag, msg = m.groups() level, ms, tag, msg = m.groups()
return LogLine(text, LineKind.LOG, t, level, int(ms), tag.strip(), msg) tag = tag.strip()
if tag == KOTA_TAG:
record, fields, words = parse_kota(msg)
return LogLine(text, LineKind.KOTA, t, level, int(ms), tag, msg,
record=record, fields=fields, words=words)
eid = None
if em := ERROR_ID_RE.match(msg):
eid, msg = em.group(1), msg[em.end():]
return LogLine(text, LineKind.LOG, t, level, int(ms), tag, msg, error_id=eid)
if BOOT_BANNER_RE.match(text): if BOOT_BANNER_RE.match(text):
return LogLine(text, LineKind.BOOT, t) return LogLine(text, LineKind.BOOT, t)
if m := RESET_REASON_RE.match(text): if m := RESET_REASON_RE.match(text):
+440 -24
View File
@@ -6,7 +6,11 @@ line cannot express:
was missed) and classification of why the previous boot ended; was missed) and classification of why the previous boot ended;
* the status-LED model (Status::set_status / set_status_conditional); * the status-LED model (Status::set_status / set_status_conditional);
* crash capture (panic / ESP_ERROR_CHECK output); * crash capture (panic / ESP_ERROR_CHECK output);
* root-cause grouping via `explained_by` chains. * root-cause grouping via `explained_by` chains;
* the structured "KOTA:" lines of firmware 1.0.16+ (diag/kota.py): exact
status/LED, restart reasons, I2C/SD/RTC/Matter/mic self-report and the
60 s health line, plus error IDs ("[E-SD-03]") looked up in
error_ids.csv. Older firmware is still diagnosed from message text.
Findings are session-wide (a reboot loop must keep its cause visible); Findings are session-wide (a reboot loop must keep its cause visible);
milestones and the LED are per boot. milestones and the LED are per boot.
@@ -22,6 +26,7 @@ from enum import IntEnum
from pathlib import Path from pathlib import Path
from ..core.logline import LineKind, LogLine from ..core.logline import LineKind, LogLine
from . import kota
from .firmware import FirmwareInfo from .firmware import FirmwareInfo
RULES_PATH = Path(__file__).with_name("rules.toml") RULES_PATH = Path(__file__).with_name("rules.toml")
@@ -48,6 +53,7 @@ LED = {
"PURPLE": ("Purple", "#8e44ad"), "PURPLE": ("Purple", "#8e44ad"),
"RED": ("Red", "#d64541"), "RED": ("Red", "#d64541"),
"BLUE": ("Blue", "#2f6fd6"), "BLUE": ("Blue", "#2f6fd6"),
"CYAN": ("Cyan", "#1fc8d8"),
"YELLOW": ("Yellow", "#e6c229"), "YELLOW": ("Yellow", "#e6c229"),
"ORANGE": ("Orange", "#f08c1a"), "ORANGE": ("Orange", "#f08c1a"),
"WHITE": ("White", "#f4f4f4"), "WHITE": ("White", "#f4f4f4"),
@@ -62,9 +68,11 @@ MILESTONES = [
("matter_started", "Matter started", True), ("matter_started", "Matter started", True),
("matter_commissioned", "Paired in Matter", True), ("matter_commissioned", "Paired in Matter", True),
("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True), ("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True),
("i2c_ok", "All sensors answer on the I2C bus", True),
("sensors_ready", "All sensors started", False), ("sensors_ready", "All sensors started", False),
("wifi_connected", "Connected to Wi-Fi", False), ("wifi_connected", "Connected to Wi-Fi", False),
("time_synced", "Clock set from the Internet", False), ("time_synced", "Clock set from the Internet", False),
("clock_verified", "Clock keeps time (RTC verified)", True),
("data_uploaded", "Data uploaded to InfluxDB", False), ("data_uploaded", "Data uploaded to InfluxDB", False),
("ota_registered", "Registered for firmware updates", False), ("ota_registered", "Registered for firmware updates", False),
("ota_checked", "Checked for firmware updates", False), ("ota_checked", "Checked for firmware updates", False),
@@ -77,11 +85,15 @@ MILESTONES = [
_INIT_FAILURES = [ _INIT_FAILURES = [
"rtc.init_failed", "sensor.init_failed", "sd.card_fail", "sd.mount_fail", "rtc.init_failed", "sensor.init_failed", "sd.card_fail", "sd.mount_fail",
"sd.env_open_fail", "influx.env_missing", "influx.init_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail",
"kota.i2c_stuck", "kota.sensor_missing", "kota.rtc_read_failed", "kota.sd_unmounted",
] ]
_NETWORK_ROOTS = [ _NETWORK_ROOTS = [
"influx.buffer_fail", "ota.check_failed", "net.dns_fail", "influx.buffer_fail", "ota.check_failed", "net.dns_fail",
"matter.not_commissioned", "wifi.disconnected", "matter.not_commissioned", "wifi.disconnected",
] ]
# Printed by the brown-out handler without a timestamp ("E BOD: ..."); on the
# ESP32-S3 the ROM then reports RTC_SW_SYS_RST, not a brown-out reset.
_BROWNOUT = "Brownout detector was triggered"
_RESET_BY_TOOL_WINDOW_S = 5.0 _RESET_BY_TOOL_WINDOW_S = 5.0
_SHORT_BOOT_MS = 60_000 _SHORT_BOOT_MS = 60_000
@@ -98,7 +110,7 @@ def _fmt(s: str, groups: dict[str, str]) -> str:
@dataclass(frozen=True) @dataclass(frozen=True)
class Rule: class Rule:
id: str id: str
pattern: re.Pattern[str] pattern: re.Pattern[str] | None # None: raised from KOTA: fields
severity: Severity severity: Severity
title: str = "" title: str = ""
meaning: str = "" meaning: str = ""
@@ -119,7 +131,7 @@ def load_rules(path: Path = RULES_PATH) -> list[Rule]:
for r in data["rule"]: for r in data["rule"]:
rules.append(Rule( rules.append(Rule(
id=r["id"], id=r["id"],
pattern=re.compile(r["pattern"]), pattern=re.compile(r["pattern"]) if "pattern" in r else None,
severity=Severity[r["severity"].upper()], severity=Severity[r["severity"].upper()],
title=r.get("title", ""), title=r.get("title", ""),
meaning=r.get("meaning", ""), meaning=r.get("meaning", ""),
@@ -145,7 +157,8 @@ def _validate(rules: list[Rule]) -> None:
known = set(ids) known = set(ids)
for r in rules: for r in rules:
for ref in (*r.explained_by, *r.clears): for ref in (*r.explained_by, *r.clears):
if ref not in known: # status.* / reboot.* findings are created by the engine itself.
if ref not in known and not ref.startswith(("status.", "reboot.")):
raise ValueError(f"{r.id}: unknown rule id {ref!r}") raise ValueError(f"{r.id}: unknown rule id {ref!r}")
for colour in (r.led, r.led_from): for colour in (r.led, r.led_from):
if colour and colour not in LED: if colour and colour not in LED:
@@ -172,11 +185,25 @@ class Finding:
resolved: bool = False resolved: bool = False
resolved_by: str | None = None resolved_by: str | None = None
evidence: deque[str] = field(default_factory=lambda: deque(maxlen=3)) evidence: deque[str] = field(default_factory=lambda: deque(maxlen=3))
error_ids: dict[str, str] = field(default_factory=dict) # "E-SD-03" -> technician action
@property @property
def active(self) -> bool: def active(self) -> bool:
return self.count >= self.min_count and not self.resolved return self.count >= self.min_count and not self.resolved
def seen_text(self) -> str:
"""'Seen 3x, first at 2.9 s after start, last at 61.2 s' (panel and service note)."""
s = f"Seen {self.count}x"
if self.first_uptime_ms is not None:
first = f"{self.first_uptime_ms / 1000:.1f}"
s += f", first at {first} s after start"
last = f"{self.last_uptime_ms / 1000:.1f}" if self.last_uptime_ms is not None else first
if self.count > 1 and last != first:
s += f", last at {last} s"
if self.resolved and self.resolved_by:
s += f" - resolved ({self.resolved_by})"
return s
@dataclass @dataclass
class BootRecord: class BootRecord:
@@ -189,8 +216,15 @@ class BootRecord:
saw_log: bool = False saw_log: bool = False
led_at_end: str | None = None led_at_end: str | None = None
panic_lines: list[str] = field(default_factory=list) panic_lines: list[str] = field(default_factory=list)
brownout: bool = False # "E BOD: Brownout detector was triggered" before the restart
recent_errors: deque[str] = field(default_factory=lambda: deque(maxlen=5)) recent_errors: deque[str] = field(default_factory=lambda: deque(maxlen=5))
intentional_restart: bool = False # factory reset / setup-mode toggle intentional_restart: bool = False # factory reset / setup-mode toggle
# KOTA: lines (firmware 1.0.16+)
kota: dict[str, LogLine] = field(default_factory=dict) # last line per record
last_restart: str | None = None # "boot ... last_restart=" (why the previous boot ended)
restart_reason: str | None = None # "restart reason=" printed just before this boot ended
previous_status: tuple[str, int] | None = None # "led previous_boot status= code="
mode: str | None = None # matter | wifi | provisioning
@dataclass @dataclass
@@ -218,14 +252,27 @@ class Report:
lines: int lines: int
mid_run: bool # no boot seen yet mid_run: bool # no boot seen yet
technician: list[str] = field(default_factory=list) # guided-check answers technician: list[str] = field(default_factory=list) # guided-check answers
led_pattern: str = "" # "2 pulses", "solid", ... (exact when from a KOTA led line)
led_meaning: str = ""
led_exact: bool = False # reported by the box itself
box: list[tuple[str, str, str]] = field(default_factory=list) # (topic, text, ok|warn|bad|info)
matter_qr: str | None = None
matter_manual: str | None = None
def led_text(self) -> str:
t = LED[self.led][0] + (f", {self.led_pattern}" if self.led_pattern else "")
return t + (f" = {self.led_meaning}" if self.led_meaning else "")
def to_text(self) -> str: def to_text(self) -> str:
out = [f"VERDICT: {self.verdict}", out = [f"VERDICT: {self.verdict}",
f"Expected status LED: {LED[self.led][0]}" f"Expected status LED: {self.led_text()}"
+ (f" (last change: {self.led_reason})" if self.led_reason else ""), + (f" (last change: {self.led_reason})" if self.led_reason else ""),
f"Firmware: {self.firmware.describe()}", f"Firmware: {self.firmware.describe()}",
f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}", f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}"]
"", "Milestones (current boot):"] if self.box:
out += ["", "Box self-report (KOTA lines):"]
out += [f" {topic}: {text}" for topic, text, _ in self.box]
out += ["", "Milestones (current boot):"]
for _, label, t in self.milestones: for _, label, t in self.milestones:
out.append(f" [{'x' if t is not None else ' '}] {label}" out.append(f" [{'x' if t is not None else ' '}] {label}"
+ (f" @ {t / 1000:.1f} s" if t is not None else "")) + (f" @ {t / 1000:.1f} s" if t is not None else ""))
@@ -256,6 +303,8 @@ class Report:
def _finding_text(f: Finding, ind: str) -> list[str]: def _finding_text(f: Finding, ind: str) -> list[str]:
lines = [f"{ind}- [{f.severity.label}] {f.title} (x{f.count})", f"{ind} {f.meaning}"] lines = [f"{ind}- [{f.severity.label}] {f.title} (x{f.count})", f"{ind} {f.meaning}"]
lines += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)] lines += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)]
for eid, action in f.error_ids.items():
lines.append(f"{ind} error {eid}" + (f": {action}" if action else ""))
if f.evidence: if f.evidence:
lines.append(f"{ind} log: {f.evidence[-1]}") lines.append(f"{ind} log: {f.evidence[-1]}")
return lines return lines
@@ -265,6 +314,8 @@ class DiagnosticsEngine:
def __init__(self, rules: list[Rule] | None = None): def __init__(self, rules: list[Rule] | None = None):
self.rules = rules if rules is not None else load_rules() self.rules = rules if rules is not None else load_rules()
self._rule_by_id = {r.id: r for r in self.rules} self._rule_by_id = {r.id: r for r in self.rules}
self._pattern_rules = [r for r in self.rules if r.pattern is not None]
self.error_ids = kota.load_error_ids()
self.reset() self.reset()
# ---------------------------------------------------------------- API # ---------------------------------------------------------------- API
@@ -281,6 +332,15 @@ class DiagnosticsEngine:
self.technician: list[str] = [] self.technician: list[str] = []
self.mac: str | None = None # from "ID: Device MAC address: ..." at boot self.mac: str | None = None # from "ID: Device MAC address: ..." at boot
self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line
self.matter_qr: str | None = None # "KOTA: matter ... qr=MT:..."
self.matter_manual: str | None = None
self.kota_seen = False # firmware prints KOTA: lines
self._reset_led_state()
def _reset_led_state(self) -> None:
self.led_pattern = ""
self.led_exact = False # a KOTA led line was seen this boot
self.status: tuple[str, int] | None = None
@property @property
def boot(self) -> BootRecord: def boot(self) -> BootRecord:
@@ -293,6 +353,8 @@ class DiagnosticsEngine:
if "reset issued by tool" in text: if "reset issued by tool" in text:
self._tool_reset_at = line.host_time self._tool_reset_at = line.host_time
return return
if _BROWNOUT in text:
self.boot.brownout = True
if line.kind == LineKind.BOOT: if line.kind == LineKind.BOOT:
self._new_boot(line.host_time, explicit=True) self._new_boot(line.host_time, explicit=True)
self.milestones["rom_boot"] = 0 self.milestones["rom_boot"] = 0
@@ -302,7 +364,7 @@ class DiagnosticsEngine:
if line.reset_reason and "POWERON" in line.reset_reason: if line.reset_reason and "POWERON" in line.reset_reason:
self.led, self.led_reason = "OFF", "power-on - firmware not started yet" self.led, self.led_reason = "OFF", "power-on - firmware not started yet"
return return
if line.kind == LineKind.LOG and line.uptime_ms is not None: if line.kind in (LineKind.LOG, LineKind.KOTA) and line.uptime_ms is not None:
b = self.boot b = self.boot
# Uptime jumped back to a fresh-boot value: the ROM banner was # Uptime jumped back to a fresh-boot value: the ROM banner was
# missed (port reconnect, capture started late). # missed (port reconnect, capture started late).
@@ -319,14 +381,20 @@ class DiagnosticsEngine:
self._panic_tail -= 1 self._panic_tail -= 1
self._crash_finding().evidence.append(text) self._crash_finding().evidence.append(text)
if line.kind == LineKind.KOTA:
self._on_kota(line)
return
self.firmware.observe(text) self.firmware.observe(text)
if m := _MAC_LINE.search(text): if m := _MAC_LINE.search(text):
self.mac = m.group(1).upper() self.mac = m.group(1).upper()
if "Advertise commission parameter" in text: if "Advertise commission parameter" in text:
self.matter_advert = text self.matter_advert = text
matched = False matched = False
for rule in self.rules: hit: list[Finding] = []
m = rule.pattern.search(text) rule_text = line.match_text
for rule in self._pattern_rules:
m = rule.pattern.search(rule_text)
if not m: if not m:
continue continue
matched = True matched = True
@@ -335,7 +403,8 @@ class DiagnosticsEngine:
groups = {k: v for k, v in m.groupdict().items() if v is not None} groups = {k: v for k, v in m.groupdict().items() if v is not None}
if rule.milestone and rule.milestone not in self.milestones: if rule.milestone and rule.milestone not in self.milestones:
self.milestones[rule.milestone] = line.uptime_ms or 0 self.milestones[rule.milestone] = line.uptime_ms or 0
if rule.led: # The box's own "KOTA: led" lines are exact; message rules only guess.
if rule.led and not self.led_exact:
self._set_led(rule.led, rule.led_from, text) self._set_led(rule.led, rule.led_from, text)
for cid in rule.clears: for cid in rule.clears:
for f in self.findings.values(): for f in self.findings.values():
@@ -345,12 +414,18 @@ class DiagnosticsEngine:
if rule.id in ("sys.factory_reset",) or "Short press:" in text: if rule.id in ("sys.factory_reset",) or "Short press:" in text:
self.boot.intentional_restart = True self.boot.intentional_restart = True
if rule.severity != Severity.IGNORE: if rule.severity != Severity.IGNORE:
self._hit(rule, groups, line) hit.append(self._hit(rule, groups, line))
self._stateful(line) self._stateful(line)
if line.error_id:
info = self.error_ids.get(line.error_id)
for f in hit:
f.error_ids[line.error_id] = info.action if info else ""
if not matched:
self._hit_error_id(line, info)
if line.level == "E": if line.level == "E":
self.boot.recent_errors.append(text) self.boot.recent_errors.append(text)
if not matched and line.tag: if not matched and line.tag and not line.error_id:
self._hit_other(line) self._hit_other(line)
def report(self) -> Report: def report(self) -> Report:
@@ -364,7 +439,8 @@ class DiagnosticsEngine:
# Worst consequence first, then the root's own severity, then order seen. # Worst consequence first, then the root's own severity, then order seen.
roots.sort(key=lambda rc: (-rc.severity, -rc.finding.severity, rc.finding.first_uptime_ms or 0)) roots.sort(key=lambda rc: (-rc.severity, -rc.finding.severity, rc.finding.first_uptime_ms or 0))
mid_run = not any(b.explicit for b in self.boots) and self.boot.index == 0 mid_run = (not any(b.explicit or "i2c" in b.kota or "boot" in b.kota for b in self.boots)
and self.boot.index == 0)
if self.lines == 0: if self.lines == 0:
verdict, vsev = "Waiting for data from the sensorbox...", None verdict, vsev = "Waiting for data from the sensorbox...", None
elif roots and roots[0].severity >= Severity.WARNING: elif roots and roots[0].severity >= Severity.WARNING:
@@ -382,8 +458,12 @@ class DiagnosticsEngine:
if not optional or mid in self.milestones] if not optional or mid in self.milestones]
# Reached milestones in the order they happened, then the pending ones. # Reached milestones in the order they happened, then the pending ones.
ms.sort(key=lambda m: (m[2] is None, m[2] or 0)) ms.sort(key=lambda m: (m[2] is None, m[2] or 0))
pattern = self.led_pattern
return Report(verdict, vsev, self.led, self.led_reason, self.firmware, ms, roots, return Report(verdict, vsev, self.led, self.led_reason, self.firmware, ms, roots,
earlier, resolved, list(self.boots), self.lines, mid_run, list(self.technician)) earlier, resolved, list(self.boots), self.lines, mid_run, list(self.technician),
led_pattern=pattern, led_meaning=kota.led_meaning(self.led, pattern) if pattern else "",
led_exact=self.led_exact, box=self._box_summary(),
matter_qr=self.matter_qr, matter_manual=self.matter_manual)
def add_confirmed(self, key: str, severity: Severity, title: str, meaning: str, def add_confirmed(self, key: str, severity: Severity, title: str, meaning: str,
actions: tuple[str, ...]) -> Finding: actions: tuple[str, ...]) -> Finding:
@@ -418,6 +498,7 @@ class DiagnosticsEngine:
self.led_reason = "restarting - LED keeps its last colour until the firmware starts" self.led_reason = "restarting - LED keeps its last colour until the firmware starts"
self.firmware = FirmwareInfo() self.firmware = FirmwareInfo()
self._panic_tail = 0 self._panic_tail = 0
self._reset_led_state()
def _set_led(self, colour: str, led_from: str | None, text: str) -> None: def _set_led(self, colour: str, led_from: str | None, text: str) -> None:
if led_from and self.led != led_from: if led_from and self.led != led_from:
@@ -435,6 +516,7 @@ class DiagnosticsEngine:
key, rule.id, rule.severity, _fmt(rule.title, groups), _fmt(rule.meaning, groups), key, rule.id, rule.severity, _fmt(rule.title, groups), _fmt(rule.meaning, groups),
tuple(_fmt(a, groups) for a in rule.actions), rule.explained_by, rule.min_count) tuple(_fmt(a, groups) for a in rule.actions), rule.explained_by, rule.min_count)
self._touch(f, line) self._touch(f, line)
return f
def _touch(self, f: Finding, line: LogLine) -> None: def _touch(self, f: Finding, line: LogLine) -> None:
f.count += 1 f.count += 1
@@ -467,7 +549,7 @@ class DiagnosticsEngine:
"sys.crash", Severity.CRITICAL, "Firmware stopped with an error and restarts", "sys.crash", Severity.CRITICAL, "Firmware stopped with an error and restarts",
"The firmware hit a fatal error. The error printed just before it is the real cause.", "The firmware hit a fatal error. The error printed just before it is the real cause.",
("See the grouped cause; otherwise save the session log for the firmware team.",), ("See the grouped cause; otherwise save the session log for the firmware team.",),
tuple(_INIT_FAILURES)) tuple(_INIT_FAILURES) + ("reboot.brownout",))
def _on_panic(self, line: LogLine) -> None: def _on_panic(self, line: LogLine) -> None:
b = self.boot b = self.boot
@@ -493,35 +575,361 @@ class DiagnosticsEngine:
("Replace the RTC module (inside the case).",)) ("Replace the RTC module (inside the case).",))
self._touch(f, line) self._touch(f, line)
# ------------------------------------------------------- KOTA: lines
def _on_kota(self, line: LogLine) -> None:
self.kota_seen = True
rec, f = line.record or "", line.fields
# A second boot line in one boot: the ROM banner and the uptime drop
# were both missed (e.g. capture started with a gap).
if rec == "boot" and "boot" in self.boot.kota:
self._new_boot(line.host_time, explicit=False)
key = f"{rec}.verify" if rec == "rtc" and "verify" in line.words else rec
self.boot.kota[key] = line
handler = getattr(self, f"_kota_{rec}", None)
if handler is not None:
handler(line, f)
def _raise(self, rule_id: str, line: LogLine, groups: dict[str, str] | None = None) -> Finding:
"""Raise a rules.toml finding from parsed KOTA fields (no pattern matching)."""
return self._hit(self._rule_by_id[rule_id], groups or {}, line)
def _resolve(self, rule_id: str, by: str, key: str | None = None) -> None:
for f in self.findings.values():
if f.rule_id == rule_id and (key is None or f.key == key) and not f.resolved:
f.resolved, f.resolved_by = True, by
def _milestone(self, mid: str, line: LogLine) -> None:
if mid not in self.milestones:
self.milestones[mid] = line.uptime_ms or 0
def _kota_boot(self, line: LogLine, f: dict[str, str]) -> None:
b = self.boot
self.firmware.observe_kota(f)
mac = f.get("mac", "")
if re.fullmatch(r"[0-9A-Fa-f]{12}", mac):
self.mac = ":".join(mac[i:i + 2] for i in range(0, 12, 2)).upper()
b.reset_reason = b.reset_reason or f.get("reset")
b.last_restart = f.get("last_restart")
b.mode = f.get("mode")
self._milestone("app_started", line)
def _kota_restart(self, line: LogLine, f: dict[str, str]) -> None:
reason = f.get("reason", "")
self.boot.restart_reason = reason
if reason in kota.INTENTIONAL_RESTARTS:
self.boot.intentional_restart = True
def _kota_i2c(self, line: LogLine, f: dict[str, str]) -> None:
if f.get("bus") == "stuck":
self._raise("kota.i2c_stuck", line, {"sda": f.get("SDA", "?"), "scl": f.get("SCL", "?")})
return
self._resolve("kota.i2c_stuck", "I2C bus free again")
missing = False
for addr, value in f.items():
if not addr.startswith("0x") or value == "?":
continue
name = value.removesuffix("(missing)")
if value.endswith("(missing)"):
missing = True
self._raise("kota.sensor_missing", line, {"sensor": name, "addr": addr})
else:
self._resolve("kota.sensor_missing", f"{name} found on I2C", f"kota.sensor_missing:{name}")
if not missing:
self._milestone("i2c_ok", line)
def _kota_rtc(self, line: LogLine, f: dict[str, str]) -> None:
if "verify" in line.words:
if f.get("ok") == "1":
self._milestone("clock_verified", line)
self._resolve("kota.rtc_not_ticking", "clock verified")
else:
self._raise("kota.rtc_not_ticking", line,
{"running": f.get("running", "?"), "delta": f.get("delta", "no reading")})
elif "read_failed" in line.words:
self._raise("kota.rtc_read_failed", line, {"err": f.get("err", "?")})
elif f.get("running") == "0":
self._raise("kota.rtc_stopped", line, {"time": f.get("time", "?")})
elif f.get("running") == "1":
self._resolve("kota.rtc_read_failed", "clock answers")
self._resolve("kota.rtc_stopped", "clock running")
def _kota_sd(self, line: LogLine, f: dict[str, str]) -> None:
if f.get("mounted") == "0":
self._raise("kota.sd_unmounted", line, {"err": f.get("err", "card missing or unreadable")})
return
self._milestone("sd_mounted", line)
self._resolve("kota.sd_unmounted", "SD card mounted")
keys = {k for k in f.get("env_keys", "").split(",") if k and k != "-"}
missing = [k for k in ("INFLUX_URL", "INFLUX_TOKEN") if k not in keys]
for k in missing:
self._raise("influx.env_missing", line, {"key": k})
if not missing:
self._milestone("env_loaded", line)
if f.get("fs") and f["fs"] != "FAT32":
self._raise("kota.sd_not_fat32", line, {"fs": f["fs"]})
free = kota.parse_size_gb(f.get("free", ""))
if free is not None and free < 0.1:
self._raise("kota.sd_full", line, {"free": f["free"]})
lines = f.get("buffer_lines", "0")
if lines.isdigit() and int(lines) > 500:
self._raise("kota.buffer_backlog", line, {"lines": lines})
def _kota_matter(self, line: LogLine, f: dict[str, str]) -> None:
if f.get("qr", "-").startswith("MT:"):
self.matter_qr = f["qr"]
if f.get("manual", "-") != "-":
self.matter_manual = f["manual"]
fabrics = f.get("fabrics", "")
if fabrics == "0":
self._raise("matter.not_commissioned", line)
elif fabrics.isdigit():
self._milestone("matter_commissioned", line)
self._resolve("matter.not_commissioned", "paired in Matter")
def _kota_mic(self, line: LogLine, f: dict[str, str]) -> None:
if f.get("enabled") == "0":
self._raise("mic.disabled", line)
elif f.get("enabled") == "1":
self._resolve("mic.disabled", "microphone switched on")
def _kota_status(self, line: LogLine, f: dict[str, str]) -> None:
w = line.words
new = w[w.index("->") + 1] if "->" in w and w.index("->") + 1 < len(w) else (w[-1] if w else "")
code = int(f["code"]) if f.get("code", "").isdigit() else 0
self._apply_status(new, code, line, f.get("reason", ""))
def _apply_status(self, status: str, code: int, line: LogLine, reason: str) -> None:
self.status = (status, code)
if not self.led_exact:
self.led, self.led_pattern = kota.led_for_status(status, code)
self.led_reason = f"status {status}" + (f" code={code}" if code else "") + (
f", reason: {reason}" if reason else "")
sf = kota.status_finding(status, code)
key = sf[0] if sf else None
for fd in self.findings.values():
if fd.rule_id.startswith("status.") and fd.key != key and not fd.resolved:
fd.resolved, fd.resolved_by = True, f"status -> {status}"
if sf:
key, sev, title, meaning, actions, expl = sf
fd = self._custom(key, Severity[sev.upper()], title, meaning, actions, expl)
self._touch(fd, line)
def _kota_led(self, line: LogLine, f: dict[str, str]) -> None:
if "previous_boot" in line.words:
code = f.get("code", "0")
self.boot.previous_status = (f.get("status", "?"), int(code) if code.isdigit() else 0)
return
if "restart_flash" in line.words:
return
colour = kota.LED_COLOURS.get(f.get("color", ""))
if colour is None:
return
pattern = kota.pattern_text(f)
self.led, self.led_pattern, self.led_exact = ("OFF", "", True) if pattern == "off" else (colour, pattern, True)
if not self.led_reason or self.status is None:
self.led_reason = "reported by the box"
def _kota_health(self, line: LogLine, f: dict[str, str]) -> None:
up = kota.parse_seconds(f.get("up", "")) or 0
status = f.get("status", "")
# Connected mid-run: the health line is the first exact status we get.
if status and (self.status is None or self.status[0] != status):
self._apply_status(status, 0, line, "from the health report")
mode = self.boot.mode
wifi = f.get("wifi")
rssi = int(f["rssi"]) if f.get("rssi", "").lstrip("-").isdigit() else None
if wifi == "connected":
self._milestone("wifi_connected", line)
self._resolve("kota.wifi_down", "Wi-Fi connected")
if rssi is not None and rssi < -75:
self._raise("kota.weak_wifi", line, {"rssi": str(rssi)})
elif rssi is not None:
self._resolve("kota.weak_wifi", "signal OK")
elif wifi == "disconnected" and mode != "provisioning":
self._raise("kota.wifi_down", line)
if f.get("time_valid") == "1":
self._milestone("time_synced", line)
self._resolve("kota.time_invalid", "time valid")
elif f.get("time_valid") == "0" and up >= 120:
self._raise("kota.time_invalid", line)
if f.get("sd") == "missing":
self._raise("kota.sd_unmounted", line, {"err": "reported missing by the health check"})
last = f.get("last_upload", "never")
if last != "never":
self._milestone("data_uploaded", line)
self._resolve("kota.no_upload", "data uploaded")
elif wifi == "connected" and up >= 300:
self._raise("kota.no_upload", line)
heap = f.get("heap", "")
if heap.isdigit() and int(heap) < 20_000:
self._raise("kota.low_heap", line, {"heap": heap})
ota = f.get("ota")
if ota == "registered":
self._milestone("ota_registered", line)
self._resolve("kota.ota_unregistered", "registered")
elif ota == "unregistered" and wifi == "connected" and mode != "provisioning":
self._raise("kota.ota_unregistered", line)
def _hit_error_id(self, line: LogLine, info: kota.ErrorId | None) -> None:
eid = line.error_id or "?"
msg = (line.message or "").strip()
msg = msg if len(msg) <= 90 else msg[:87] + "..."
if info:
sev = Severity.WARNING if info.severity == "warning" else Severity.ERROR
f = self._custom(f"eid.{eid}", sev, f"{info.module_name}: {msg}",
f"The firmware reported error {eid} ({info.module_name}).",
(info.action, "If it repeats, save the session log for the firmware team."))
f.rule_id = f"eid.{info.module}"
f.error_ids[eid] = info.action
else:
f = self._custom(f"eid.{eid}", Severity.WARNING, f"Error {eid}: {msg}",
"This error ID is not in the tool's error table (newer firmware?).",
("Save the session log and send it to the firmware team.",))
self._touch(f, line)
def _box_summary(self) -> list[tuple[str, str, str]]:
"""What the box reported about itself in this boot (KOTA lines), for the panel."""
k = self.boot.kota
out: list[tuple[str, str, str]] = []
if ln := k.get("boot"):
f = ln.fields
lr = f.get("last_restart", "none")
bad = kota.restart_finding(lr) is not None
out.append(("Boot", f"v{f.get('fw', '?')} build {f.get('build', '?')}, mode {f.get('mode', '?')}, "
f"reset {f.get('reset', '?')}, last restart: {lr}", "warn" if bad else "ok"))
if ps := self.boot.previous_status:
if ps[0] != "OK":
out.append(("Before restart", f"status {ps[0]}" + (f" code={ps[1]}" if ps[1] else ""), "warn"))
if ln := k.get("i2c"):
f = ln.fields
if f.get("bus") == "stuck":
out.append(("I2C", f"bus stuck (SDA={f.get('SDA')}, SCL={f.get('SCL')})", "bad"))
else:
devs = [(v.removesuffix("(missing)"), v.endswith("(missing)"))
for a, v in f.items() if a.startswith("0x") and v != "?"]
miss = [n for n, m in devs if m]
text = ("missing: " + ", ".join(miss) + "; " if miss else "") + "found: " + (
", ".join(n for n, m in devs if not m) or "none")
out.append(("I2C", text, "bad" if miss else "ok"))
if ln := k.get("sd"):
f = ln.fields
if f.get("mounted") == "0":
out.append(("SD card", "not mounted" + (f" ({f['err']})" if f.get("err") else ""), "bad"))
else:
keys = f.get("env_keys", "-")
ok = "INFLUX_URL" in keys and "INFLUX_TOKEN" in keys
out.append(("SD card", f"{f.get('type', '?')} {f.get('size', '?')} {f.get('fs', '?')}, "
f"{f.get('free', '?')} free, ENV keys: {keys}, buffered: {f.get('buffer_lines', '?')}",
"ok" if ok else "bad"))
rtc, verify = k.get("rtc"), k.get("rtc.verify")
if rtc or verify:
parts, state = [], "ok"
if rtc:
if "read_failed" in rtc.words:
parts.append(f"read failed ({rtc.fields.get('err', '?')})")
state = "bad"
else:
running = rtc.fields.get("running") == "1"
parts.append(("running" if running else "STOPPED") + f", time {rtc.fields.get('time', '?')}")
state = "ok" if running else "warn"
if verify:
ok = verify.fields.get("ok") == "1"
parts.append("verified ticking" if ok else "NOT keeping time")
state = state if ok else "bad"
out.append(("Clock (RTC)", "; ".join(parts), state))
if ln := k.get("matter"):
f = ln.fields
fabrics = f.get("fabrics", "?")
out.append(("Matter", f"{'paired' if fabrics not in ('0', '?') else 'NOT paired'} (fabrics {fabrics}), "
f"pairing window {f.get('window', '?')}", "ok" if fabrics not in ("0", "?") else "warn"))
if ln := k.get("mic"):
on = ln.fields.get("enabled") == "1"
out.append(("Microphone", "on" if on else "switched OFF (switch on the back)", "ok" if on else "info"))
if ln := k.get("health"):
f = ln.fields
wifi = f.get("wifi", "?")
text = (f"up {f.get('up', '?')}, status {f.get('status', '?')}, Wi-Fi {wifi}"
+ (f" ({f.get('rssi')} dBm)" if wifi == "connected" else "")
+ f", time {'OK' if f.get('time_valid') == '1' else 'NOT set'}, SD {f.get('sd', '?')}, "
f"buffer {f.get('buffer', '?')}, heap {f.get('heap', '?')}, last upload {f.get('last_upload', '?')}"
+ ("" if f.get("last_upload") == "never" else " ago") + f", OTA {f.get('ota', '?')}")
good = f.get("status") == "OK" and wifi == "connected" and f.get("last_upload") != "never"
out.append(("Health", text, "ok" if good else "warn"))
return out
# ------------------------------------------------------------ reboots
def _reboot_findings(self) -> list[Finding]: def _reboot_findings(self) -> list[Finding]:
"""Classify why each earlier boot ended (needs the next boot's reset reason).""" """Classify why each earlier boot ended (needs the next boot's reset reason)."""
out: list[Finding] = [] out: list[Finding] = []
short_unexpected: list[BootRecord] = [] short_unexpected: list[BootRecord] = []
cur = self.boot
# The box keeps its status across a restart ("led previous_boot"): if
# it was in an error state right before the latest restart (often the
# technician's Reset & capture), that state is worth showing.
if cur.previous_status and cur.previous_status[0] not in ("OK", "UPDATING", "SELF_CHECK"):
sf = kota.status_finding(*cur.previous_status)
st, code = cur.previous_status
# Only informative once the box reports OK again in this boot.
sev = Severity.INFO if self.status and self.status[0] == "OK" else Severity.WARNING
f = Finding("boot.previous_status", "boot.previous_status", sev,
f"Before the last restart: {sf[2] if sf else st}",
"The box remembers its status across a restart. Right before the latest restart it was in "
f"state {st}" + (f" code={code}" if code else "") + ". "
+ (sf[3] if sf else "") + " Check whether the problem comes back in this boot.",
sf[4] if sf else ())
f.count, f.boot, f.last_seen = 1, cur.index, cur.host_time
f.evidence.append(f"led previous_boot status={st} code={code}")
out.append(f)
for prev, nxt in zip(self.boots, self.boots[1:]): for prev, nxt in zip(self.boots, self.boots[1:]):
if not prev.saw_log and prev.index == 0: if not prev.saw_log and prev.index == 0:
continue # nothing captured before the first boot continue # nothing captured before the first boot
reason = nxt.reset_reason or "unknown" reason = nxt.reset_reason or "unknown"
if nxt.tool_reset or prev.intentional_restart: if nxt.tool_reset or prev.intentional_restart:
continue continue
# Firmware 1.0.16+ records why it restarted itself.
last = nxt.last_restart or prev.restart_reason
if last in kota.INTENTIONAL_RESTARTS:
continue
key, sev, title, meaning, actions, expl = None, Severity.INFO, "", "", (), () key, sev, title, meaning, actions, expl = None, Severity.INFO, "", "", (), ()
if "USB_UART_CHIP_RESET" in reason: rf = kota.restart_finding(last) if last else None
if prev.brownout or "BROWN" in reason or last == "brownout":
# A crash right after a brown-out is its effect, not a firmware bug.
key, sev, title = "reboot.brownout", Severity.CRITICAL, "Power supply voltage drops (brown-out)"
meaning = ("The supply voltage fell too low and the chip reset itself - typically when the "
"Wi-Fi/Bluetooth radio starts. The box cannot run like this.")
actions = ("Switch the box's power switch ON - USB from a laptop alone may not be enough.",
"Check the power supply, cable and connectors; try another supply.",
"If it persists on a good supply: the board's power circuit is faulty.")
elif rf and not (last == "panic" and prev.panic_lines):
sev_name, title, meaning, actions = rf
sev = Severity[sev_name.upper()]
key = {"status_watchdog": "reboot.status_watchdog", "panic": "reboot.crash",
"brownout": "reboot.brownout", "task_wdt": "reboot.watchdog", "int_wdt": "reboot.watchdog",
"wdt": "reboot.watchdog"}.get(last, f"reboot.{last}")
if nxt.previous_status and nxt.previous_status[0] != "OK":
st, code = nxt.previous_status
meaning += f" Before the restart the box was in state {st}" + (f" code={code}" if code else "") + "."
if last == "status_watchdog":
expl = tuple(_INIT_FAILURES) + kota._SENSOR_ROOTS + ("status.error", "sys.out_of_resources")
elif last == "status_oom_watchdog":
expl = ("sys.out_of_resources",)
elif rf:
pass # panic with captured crash output: covered by sys.crash
elif "USB_UART_CHIP_RESET" in reason:
key, title = "reboot.usb", "Box was reset over USB" key, title = "reboot.usb", "Box was reset over USB"
meaning = "The board was reset through its USB port (a flashing tool, a serial program, or this tool in an earlier session)." meaning = "The board was reset through its USB port (a flashing tool, a serial program, or this tool in an earlier session)."
elif "POWERON" in reason: elif "POWERON" in reason:
key, title = "reboot.power", "Box was switched off and on" key, title = "reboot.power", "Box was switched off and on"
meaning = "Power was interrupted (power switch, cable or supply)." meaning = "Power was interrupted (power switch, cable or supply)."
actions = ("If nobody switched it off: check the power supply and cable.",) actions = ("If nobody switched it off: check the power supply and cable.",)
elif "BROWN" in reason:
key, sev, title = "reboot.brownout", Severity.ERROR, "Power supply voltage dropped (brown-out)"
meaning = "The supply voltage fell too low and the chip reset itself."
actions = ("Check the power supply, cable and connectors.",)
elif "WDT" in reason: elif "WDT" in reason:
key, sev, title = "reboot.watchdog", Severity.ERROR, "Firmware froze and was restarted by the watchdog" key, sev, title = "reboot.watchdog", Severity.ERROR, "Firmware froze and was restarted by the watchdog"
meaning = "The firmware stopped responding; the hardware watchdog restarted it." meaning = "The firmware stopped responding; the hardware watchdog restarted it."
actions = ("Save the session log for the firmware team.",) actions = ("Save the session log for the firmware team.",)
elif prev.panic_lines: elif prev.panic_lines:
pass # covered by sys.crash pass # covered by sys.crash
elif prev.led_at_end in ("PURPLE", "RED") and prev.last_uptime_ms >= 140_000: elif (not nxt.last_restart and prev.led_at_end in ("PURPLE", "RED")
and prev.last_uptime_ms >= 140_000): # older firmware: no restart record
key, title = "reboot.status_watchdog", "Box restarted itself after 2.5 minutes of errors" key, title = "reboot.status_watchdog", "Box restarted itself after 2.5 minutes of errors"
meaning = "By design the firmware restarts when it stays in an error state (purple/red) for 150 s." meaning = "By design the firmware restarts when it stays in an error state (purple/red) for 150 s."
actions = ("Fix the grouped cause; the restarts stop by themselves.",) actions = ("Fix the grouped cause; the restarts stop by themselves.",)
@@ -540,10 +948,18 @@ class DiagnosticsEngine:
"reboot.loop", Severity.CRITICAL, "Reboot loop", "reboot.loop", Severity.CRITICAL, "Reboot loop",
f"The box restarted {len(short_unexpected)} times, about every {avg:.0f} s. " f"The box restarted {len(short_unexpected)} times, about every {avg:.0f} s. "
"The error printed just before each restart is the cause.", "The error printed just before each restart is the cause.",
("Fix the grouped cause below.",), tuple(_INIT_FAILURES), self.boot) ("Fix the grouped cause below.",), tuple(_INIT_FAILURES) + ("reboot.brownout",), self.boot)
f.count = len(short_unexpected) f.count = len(short_unexpected)
out.append(f) out.append(f)
return out # One finding per cause, counting how often it happened.
merged: dict[str, Finding] = {}
for f in out:
if f.key in merged:
merged[f.key].count += f.count
merged[f.key].evidence.extend(f.evidence)
else:
merged[f.key] = f
return list(merged.values())
def _reboot_finding(self, key, sev, title, meaning, actions, expl, boot: BootRecord) -> Finding: def _reboot_finding(self, key, sev, title, meaning, actions, expl, boot: BootRecord) -> Finding:
f = Finding(key, key, sev, title, meaning, actions, expl) f = Finding(key, key, sev, title, meaning, actions, expl)
+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
+55 -5
View File
@@ -1,8 +1,14 @@
"""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.
Firmware 1.0.16+ prints "KOTA: boot fw=1.0.16 build=4f10989e ..." (version
unique per build, first 8 hex digits of the ELF SHA256). Builds found in the
tool's firmware/ folder are registered with `register_local_build` so the
box's build can be shown with its compile date.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,6 +40,27 @@ KNOWN: dict[str, KnownFirmware] = {
), ),
} }
# elf sha (full or prefix) -> (version, compile time, file name) of builds in firmware/
LOCAL_BUILDS: dict[str, tuple[str, str, str]] = {}
def register_local_build(elf_sha: str, version: str, built: str, file_name: str) -> None:
LOCAL_BUILDS[elf_sha.lower()] = (version, built, file_name)
def same_build(a: str | None, b: str | None) -> bool:
"""ELF SHA prefixes of different lengths (8 from KOTA: boot, 9+ from
app_init, 64 from an image) name the same build if they agree."""
if not a or not b:
return False
n = min(len(a), len(b))
return n >= 6 and a[:n].lower() == b[:n].lower()
def local_build(elf_sha: str | None) -> tuple[str, str, str] | None:
return next((v for sha, v in LOCAL_BUILDS.items() if same_build(sha, elf_sha)), None)
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})") _ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
_VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)") _VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)")
_BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$") _BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$")
@@ -51,8 +78,21 @@ class FirmwareInfo:
version: str | None = None version: str | None = None
built: str | None = None built: str | None = None
idf: str | None = None idf: str | None = None
hw: str | None = None
evidence: set[str] = field(default_factory=set) evidence: set[str] = field(default_factory=set)
def observe_kota(self, fields: dict[str, str]) -> None:
"""'KOTA: boot fw=1.0.16 build=4f10989e hw=2 ...'"""
if fields.get("fw"):
self.version = fields["fw"]
build = fields.get("build", "")
# app_init's longer prefix is kept if it agrees.
if build and not same_build(self.elf_sha, build):
self.elf_sha = build.lower()
if fields.get("hw"):
self.hw = fields["hw"]
self.evidence.add("kota") # 1.0.16+: SD Wi-Fi and setup portal exist
def observe(self, text: str) -> None: def observe(self, text: str) -> None:
if m := _ELF.search(text): if m := _ELF.search(text):
self.elf_sha = m.group("sha") self.elf_sha = m.group("sha")
@@ -73,14 +113,14 @@ class FirmwareInfo:
def known(self) -> KnownFirmware | None: def known(self) -> KnownFirmware | None:
if not self.elf_sha: if not self.elf_sha:
return None return None
return next((k for sha, k in KNOWN.items() if self.elf_sha.startswith(sha)), None) return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None)
@property @property
def supports_sd_wifi(self) -> bool | None: def supports_sd_wifi(self) -> bool | None:
"""True/False when determinable, None when unknown (no boot seen).""" """True/False when determinable, None when unknown (no boot seen)."""
if k := self.known: if k := self.known:
return k.sd_wifi return k.sd_wifi
if "sd_wifi" in self.evidence: if "sd_wifi" in self.evidence or "kota" in self.evidence:
return True return True
if "legacy_v1" in self.evidence: if "legacy_v1" in self.evidence:
return False return False
@@ -89,6 +129,16 @@ class FirmwareInfo:
def describe(self) -> str: def describe(self) -> str:
if k := self.known: if k := self.known:
return f"{k.name} - {k.source}" return f"{k.name} - {k.source}"
if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)):
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: if self.elf_sha:
built = f", built {self.built}" if self.built else "" built = f", built {self.built}" if self.built else ""
return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)" return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)"
+1 -1
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"}
+12 -7
View File
@@ -33,7 +33,7 @@ start = "led"
id = "led" id = "led"
type = "led" type = "led"
text = "Look at the RGB status LED on the box (no need to open it). Which colour is it?" text = "Look at the RGB status LED on the box (no need to open it). Which colour is it?"
route = { GREEN = "heartbeat", PURPLE = "guide:network", RED = "guide:sensors", BLUE = "guide:rtc_i2c", WHITE = "guide:white", YELLOW = "guide:update", ORANGE = "guide:update", OFF = "guide:power", CYCLING = "guide:reboot_loop" } route = { GREEN = "heartbeat", PURPLE = "guide:network", RED = "guide:sensors", BLUE = "guide:rtc_i2c", WHITE = "guide:white", YELLOW = "guide:update", ORANGE = "guide:update", CYAN = "guide:network", OFF = "guide:power", CYCLING = "guide:reboot_loop" }
[[guide.step]] [[guide.step]]
id = "heartbeat" id = "heartbeat"
@@ -72,7 +72,8 @@ actions = [
id = "network" id = "network"
title = "Purple LED / no Internet" title = "Purple LED / no Internet"
summary = "Pairing in Matter, site Wi-Fi and Internet checks, with live confirmation." summary = "Pairing in Matter, site Wi-Fi and Internet checks, with live confirmation."
recommend = ["led:PURPLE", "matter.not_commissioned", "net.dns_fail", "influx.buffer_fail", "ota.registration_failed"] recommend = ["led:PURPLE", "led:CYAN", "matter.not_commissioned", "net.dns_fail", "influx.buffer_fail", "ota.registration_failed",
"status.conn", "status.conn.1", "kota.wifi_down", "kota.no_upload", "kota.weak_wifi", "kota.ota_unregistered"]
start = "paired" start = "paired"
[[guide.step]] [[guide.step]]
@@ -249,7 +250,7 @@ actions = ["Keep the tool connected a few more minutes and watch the Diagnosis t
id = "rtc_i2c" id = "rtc_i2c"
title = "Blue LED - clock / I2C cable" title = "Blue LED - clock / I2C cable"
summary = "Re-seat the I2C cable and verify the start-up live." summary = "Re-seat the I2C cable and verify the start-up live."
recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors"] recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors", "kota.i2c_stuck", "kota.rtc_read_failed", "status.rtc.1", "status.rtc.2"]
start = "explain" start = "explain"
[[guide.step]] [[guide.step]]
@@ -310,7 +311,8 @@ actions = ["Replace the RTC module, or send the box for repair with the session
id = "white" id = "white"
title = "White LED - SD card / reset / setup mode" title = "White LED - SD card / reset / setup mode"
summary = "Tell apart the three white states and check the SD card." summary = "Tell apart the three white states and check the SD card."
recommend = ["led:WHITE", "sd.card_fail", "sd.mount_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail"] recommend = ["led:WHITE", "sd.card_fail", "sd.mount_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail",
"kota.sd_unmounted", "status.sd", "status.portal", "status.reset"]
start = "button" start = "button"
[[guide.step]] [[guide.step]]
@@ -389,7 +391,9 @@ actions = [
id = "sensors" id = "sensors"
title = "Red LED - sensor or hardware fault" title = "Red LED - sensor or hardware fault"
summary = "Find the failing sensor and verify after re-seating its cable." summary = "Find the failing sensor and verify after re-seating its cable."
recommend = ["led:RED", "sensor.init_failed", "sensor.selfcheck_failed", "sys.out_of_resources", "ota.selfcheck_failed"] recommend = ["led:RED", "sensor.init_failed", "sensor.selfcheck_failed", "sys.out_of_resources", "ota.selfcheck_failed",
"kota.sensor_missing", "status.error", "status.error.1", "status.error.2", "status.error.3", "status.error.4",
"status.error.5", "status.error.6", "status.oom"]
start = "which" start = "which"
[[guide.step]] [[guide.step]]
@@ -470,7 +474,8 @@ actions = ["Save the session log and send it to the firmware team."]
id = "reboot_loop" id = "reboot_loop"
title = "LED keeps changing / box restarts" title = "LED keeps changing / box restarts"
summary = "Record several start-ups and find the error just before each restart." summary = "Record several start-ups and find the error just before each restart."
recommend = ["led:CYCLING", "reboot.loop", "sys.crash", "reboot.unexpected", "reboot.brownout", "reboot.watchdog"] recommend = ["led:CYCLING", "reboot.loop", "sys.crash", "reboot.unexpected", "reboot.brownout", "reboot.watchdog",
"reboot.crash", "reboot.status_watchdog", "reboot.status_oom_watchdog"]
start = "record" start = "record"
[[guide.step]] [[guide.step]]
@@ -606,7 +611,7 @@ actions = [
id = "clock" id = "clock"
title = "Clock not set" title = "Clock not set"
summary = "Why readings are on hold and when the coin cell matters." summary = "Why readings are on hold and when the coin cell matters."
recommend = ["rtc.clock_not_running", "rtc.lost_after_sync"] recommend = ["rtc.clock_not_running", "rtc.lost_after_sync", "kota.rtc_stopped", "kota.rtc_not_ticking", "kota.time_invalid"]
start = "internet" start = "internet"
[[guide.step]] [[guide.step]]
+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
+162 -2
View File
@@ -8,6 +8,9 @@
# Fields: # Fields:
# id unique, "<category>.<name>" # id unique, "<category>.<name>"
# pattern Python regex (search). Named groups can be used in `title`. # pattern Python regex (search). Named groups can be used in `title`.
# Matched against the line with its error ID ("[E-SD-03] ")
# removed. Rules without a pattern ("kota.*") are raised by
# the engine from the structured KOTA: lines (diag/kota.py).
# severity critical | error | warning | info | ignore # severity critical | error | warning | info | ignore
# ignore = never shown (benign ESP_LOGE lines, LED-only rules) # ignore = never shown (benign ESP_LOGE lines, LED-only rules)
# title short technician-facing headline # title short technician-facing headline
@@ -121,7 +124,7 @@ led_from = "PURPLE"
[[rule]] [[rule]]
id = "benign.mac" id = "benign.mac"
pattern = 'ID: Device MAC address' pattern = 'Device MAC address'
severity = "ignore" severity = "ignore"
[[rule]] [[rule]]
@@ -134,6 +137,14 @@ id = "benign.zcl"
pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL' pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL'
severity = "ignore" severity = "ignore"
[[rule]]
id = "benign.wifi_start"
# Harmless Wi-Fi noise seen on every 1.0.16 boot: Matter asks for a connect
# while the station is already connecting, and an unpaired box polls a
# network it does not have yet (matter.not_commissioned covers that case).
pattern = 'wifi:sta is connecting, return error|esp_wifi_connect\(\) failed: ESP_ERR_WIFI_CONN|Failed to get configured network when updating network status'
severity = "ignore"
[[rule]] [[rule]]
id = "benign.connect_chain" id = "benign.connect_chain"
# Transport-level echo of a failure that a more specific rule reports. # Transport-level echo of a failure that a more specific rule reports.
@@ -222,7 +233,7 @@ id = "influx.buffer_fail"
pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer' pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer'
severity = "error" severity = "error"
title = "Measurements cannot be uploaded to InfluxDB" title = "Measurements cannot be uploaded to InfluxDB"
meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple and the box restarts itself after about 2.5 minutes in this state." meaning = "Uploads keep failing, so readings are stored on the SD card and sent later. The LED turns purple (firmware v1.0 also restarts itself after about 2.5 minutes in this state)."
actions = [ actions = [
"Fix the network problem first (see the finding this is grouped under).", "Fix the network problem first (see the finding this is grouped under).",
"Buffered data is uploaded automatically once the connection works.", "Buffered data is uploaded automatically once the connection works.",
@@ -340,6 +351,7 @@ actions = [
"Open the case and check the I2C cable/connector between the boards.", "Open the case and check the I2C cable/connector between the boards.",
"If the cable is fine: the RTC module is faulty.", "If the cable is fine: the RTC module is faulty.",
] ]
explained_by = ["kota.i2c_stuck"]
led = "BLUE" led = "BLUE"
[[rule]] [[rule]]
@@ -353,6 +365,7 @@ actions = [
"Give the box Internet access: the clock is set automatically on the first connection.", "Give the box Internet access: the clock is set automatically on the first connection.",
"If this comes back after every power-off: replace the RTC coin cell (inside the case).", "If this comes back after every power-off: replace the RTC coin cell (inside the case).",
] ]
explained_by = ["kota.rtc_stopped"]
[[rule]] [[rule]]
id = "i2c.errors" id = "i2c.errors"
@@ -362,6 +375,7 @@ min_count = 2
title = "I2C bus communication errors" title = "I2C bus communication errors"
meaning = "Sensors are not answering reliably on the I2C bus: loose connector, damaged cable, or a failing sensor." meaning = "Sensors are not answering reliably on the I2C bus: loose connector, damaged cable, or a failing sensor."
actions = ["Open the case and check the I2C cable/connectors between the boards."] actions = ["Open the case and check the I2C cable/connectors between the boards."]
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
# ------------------------------------------------------------------ sensors # ------------------------------------------------------------------ sensors
@@ -376,6 +390,7 @@ actions = [
"Open the case and check the {sensor} connector/cable.", "Open the case and check the {sensor} connector/cable.",
"If several sensors fail in turn: check the shared I2C cable.", "If several sensors fail in turn: check the shared I2C cable.",
] ]
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
led = "RED" led = "RED"
[[rule]] [[rule]]
@@ -570,3 +585,148 @@ title = "A sensor returned no data"
meaning = "One of the sensors skipped its reading; the preceding log lines name the sensor." meaning = "One of the sensors skipped its reading; the preceding log lines name the sensor."
actions = ["Check the log lines just before this message for the sensor name."] actions = ["Check the log lines just before this message for the sensor name."]
explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"] explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"]
# ------------------------------------------------ structured KOTA: lines
# Firmware 1.0.16+ reports its state as "KOTA: <record> key=value ..." lines.
# These rules have no pattern: the engine raises them from the parsed fields
# (see diag/kota.py for the line formats). Named values in {braces} come
# from the line's fields.
[[rule]]
id = "kota.i2c_stuck"
severity = "critical"
title = "I2C bus stuck (SDA={sda}, SCL={scl})"
meaning = "A data line of the sensor bus is held low before any sensor is used: a shorted or broken sensor cable, or a faulty sensor. LED blue, 2 pulses."
actions = [
"Open the case and check the I2C cable between the boards.",
"Unplug the sensors one by one to find the one holding the bus.",
]
[[rule]]
id = "kota.sensor_missing"
split_by = "sensor"
severity = "error"
title = "{sensor} does not answer on the I2C bus"
meaning = "The start-up scan did not find the {sensor} at address {addr}. The other sensors answer, so the bus itself works: the {sensor} cable, connector or the sensor is faulty."
actions = [
"Open the case and re-seat the {sensor} cable/connector.",
"If it is still missing after a restart: replace the {sensor}.",
]
explained_by = ["kota.i2c_stuck"]
[[rule]]
id = "kota.rtc_read_failed"
severity = "critical"
title = "Clock chip (RTC) cannot be read ({err})"
meaning = "The DS1307 clock does not answer. It is on the shared I2C bus, so a loose cable also causes this."
actions = [
"Open the case and check the I2C cable/connector between the boards.",
"If the cable is fine: replace the RTC module.",
]
explained_by = ["kota.i2c_stuck", "kota.sensor_missing"]
[[rule]]
id = "kota.rtc_stopped"
severity = "warning"
title = "Clock chip was stopped at start-up (time {time})"
meaning = "The RTC answers but its clock was not running - typical after its coin cell was removed or is empty. It is set again from the Internet once the box is online; until then readings are held."
actions = [
"Give the box Internet access: the clock is set automatically.",
"If this comes back after every power-off: replace the RTC coin cell (inside the case).",
]
[[rule]]
id = "kota.rtc_not_ticking"
severity = "error"
title = "Clock chip does not keep time"
meaning = "The clock was set from the Internet, but 2 s later it had not advanced correctly (running={running}, delta={delta}). The RTC module or its crystal is faulty."
actions = ["Replace the RTC module (inside the case)."]
[[rule]]
id = "kota.sd_unmounted"
severity = "critical"
title = "SD card not mounted ({err})"
meaning = "The firmware cannot use the SD card: missing, not seated, not FAT32 or damaged. Without it the box cannot read ENV.TXT. LED white, fast blinking."
actions = [
"Open the case and re-seat the SD card.",
"Re-provision the card with the tool (FAT32, MBR) or try a freshly provisioned card.",
]
[[rule]]
id = "kota.sd_not_fat32"
severity = "warning"
title = "SD card file system is {fs}, not FAT32"
meaning = "The card works, but the provisioning standard is FAT32."
actions = ["Re-provision the card with the tool when convenient."]
[[rule]]
id = "kota.sd_full"
severity = "warning"
title = "SD card is almost full ({free} free)"
meaning = "Offline readings may soon not be stored."
actions = ["Check the buffered readings upload (Internet access), or replace the card."]
[[rule]]
id = "kota.buffer_backlog"
severity = "warning"
title = "{lines} readings are waiting on the SD card to be uploaded"
meaning = "The box stored readings while it could not upload them. They are sent automatically once the connection works."
actions = ["Fix the connection problem if there is one; watch the health line: the buffer should shrink."]
explained_by = ["kota.wifi_down", "matter.not_commissioned", "status.conn", "status.conn.1", "net.dns_fail", "influx.auth"]
[[rule]]
id = "kota.wifi_down"
severity = "error"
min_count = 2
title = "Box has no Wi-Fi connection"
meaning = "The periodic health report says Wi-Fi is disconnected, so the box cannot upload data or receive updates."
actions = [
"Check the site Wi-Fi is on, 2.4 GHz and in range.",
"Matter boxes: check the box is still paired in the Matter app. Wi-Fi-from-SD boxes: check SSID/PASSWORD in ENV.TXT.",
]
explained_by = ["matter.not_commissioned", "wifi.setup_portal", "wifi.disconnected"]
[[rule]]
id = "kota.weak_wifi"
severity = "warning"
title = "Weak Wi-Fi signal ({rssi} dBm)"
meaning = "The connection works but the signal is weak; uploads may fail intermittently. Below about -75 dBm is unreliable."
actions = ["Move the box or the access point closer, or add a repeater."]
[[rule]]
id = "kota.time_invalid"
severity = "warning"
title = "Box has no valid time - readings are on hold"
meaning = "Readings are only stored with a valid timestamp. The time comes from the RTC or from the Internet."
actions = [
"Give the box Internet access so it can set its clock.",
"If it is online and this persists: check the RTC findings.",
]
explained_by = ["kota.wifi_down", "matter.not_commissioned", "kota.rtc_not_ticking", "kota.rtc_read_failed", "kota.rtc_stopped", "rtc.clock_not_running", "net.dns_fail"]
[[rule]]
id = "kota.no_upload"
severity = "error"
title = "Connected to Wi-Fi, but no data uploaded yet (5+ min)"
meaning = "The box has Wi-Fi but no upload to InfluxDB has succeeded yet."
actions = [
"Check the network has Internet (open a website on a phone on the same Wi-Fi).",
"Check INFLUX_URL / INFLUX_TOKEN in ENV.TXT (re-provision the SD card).",
]
explained_by = ["kota.time_invalid", "influx.auth", "influx.not_found", "status.conn.1", "net.dns_fail", "net.tls_fail", "influx.server_error"]
[[rule]]
id = "kota.low_heap"
severity = "warning"
title = "Firmware memory is low ({heap} bytes free)"
meaning = "Internal memory is running low; the firmware may fail to start tasks and restart."
actions = ["Save the session log for the firmware team."]
[[rule]]
id = "kota.ota_unregistered"
severity = "warning"
min_count = 3
title = "Online, but not registered for firmware updates"
meaning = "The box has Wi-Fi but is still not registered with the update server, so it will not receive new firmware."
actions = ["Check the network allows HTTPS to bin.kota-app.com; registration is retried automatically."]
explained_by = ["ota.registration_failed", "net.dns_fail", "net.tls_fail"]
+72 -115
View File
@@ -1,14 +1,16 @@
"""Firmware images: validation, integrity and layout. """Firmware images: validation, integrity, layout and the firmware folder.
The tool flashes ONLY the .bin files in its own firmware/ folder (next to
kota_tech/). There is no import and no per-user library: to offer a build,
the firmware team puts the file into that folder. Images are listed by the
compile date stored in the firmware itself (ESP-IDF app descriptor), newest
first, so the file name does not matter.
Two origins:
* factory - bundled with the tool (firmware/firmware.toml), SHA-256 locked;
* developer - a newer build the firmware team sends to technicians,
imported into a per-user library (~/KotaTechnicianTool/firmware).
Two kinds: Two kinds:
* merged - bootloader at 0x0 + partition table + app (like new_system.bin); * merged - bootloader at 0x0 + partition table + app (merged-binary.bin);
App-only or Full flashing; App-only or Full flashing;
* app - the application binary only (build/kota-sensorboard.bin); * app - the application binary only (build/kota-sensorboard.bin);
App-only flashing, laid out like the factory image. App-only flashing, laid out like a merged image in the folder.
Every image is checked structurally (ESP32-S3, kota-sensorboard app Every image is checked structurally (ESP32-S3, kota-sensorboard app
descriptor, segment checksum and the appended SHA-256) so a truncated or descriptor, segment checksum and the appended SHA-256) so a truncated or
@@ -19,17 +21,11 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import hashlib import hashlib
import json
import shutil
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"
LIBRARY_DIR = Path.home() / "KotaTechnicianTool" / "firmware"
LIBRARY_INDEX = "library.json"
PT_OFFSET = 0x8000 PT_OFFSET = 0x8000
PT_SIZE = 0xC00 PT_SIZE = 0xC00
@@ -58,7 +54,7 @@ class Partition:
class AppInfo: class AppInfo:
project: str project: str
version: str version: str
built: str # "Jun 29 2026 11:03:32" as in the app descriptor built: str # "Sep 28 2026 15:07:57" as in the app descriptor
idf: str idf: str
elf_sha: str # hex elf_sha: str # hex
@@ -71,13 +67,16 @@ class AppInfo:
except (ValueError, KeyError): except (ValueError, KeyError):
return None return None
@property
def compiled(self) -> str:
"""'2026-09-28 15:07' (ISO-like, sortable) or the raw descriptor text."""
t = self.built_at
return t.strftime("%Y-%m-%d %H:%M") if t else self.built
@dataclass(frozen=True) @dataclass(frozen=True)
class FirmwareImage: class FirmwareImage:
path: Path path: Path
name: str
notes: str
origin: str # "factory" | "developer"
kind: str # "merged" | "app" kind: str # "merged" | "app"
sha256: str sha256: str
flash_size: str flash_size: str
@@ -86,16 +85,15 @@ class FirmwareImage:
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
built_by: str = "" # who compiled a developer build (shown in the UI)
@property
def name(self) -> str:
return self.path.name
@property @property
def data(self) -> bytes: def data(self) -> bytes:
return self.path.read_bytes() return self.path.read_bytes()
@property
def is_developer(self) -> bool:
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:
@@ -110,11 +108,9 @@ class FirmwareImage:
return b"\xff" * self.partition("otadata").size return b"\xff" * self.partition("otadata").size
def label(self) -> str: def label(self) -> str:
if self.origin == "factory": """Choice text: compile date first (that is what the technician picks by)."""
tag = "factory" kind = "" if self.kind == "merged" else ", app only"
else: return f"Compiled {self.app.compiled} - v{self.app.version} [{self.app.elf_sha[:8]}]{kind} ({self.name})"
tag = "developer build" + (f" - built by {self.built_by}" if self.built_by else "")
return f"{self.app.version} built {self.app.built} [{self.app.elf_sha[:9]}] - {tag}"
# ---------------------------------------------------------------- parsing # ---------------------------------------------------------------- parsing
@@ -184,10 +180,10 @@ def _app_info(data: bytes, offset: int) -> AppInfo:
return info return info
def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "factory",
reference: FirmwareImage | None = None, built_by: str = "") -> FirmwareImage: def inspect_image(path: Path, reference: FirmwareImage | None = None) -> FirmwareImage:
"""Validate a merged or app-only image. App-only images need a `reference` """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
@@ -201,7 +197,8 @@ def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "fa
kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?") 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 "
"to take the flash layout from")
kind, app_off = "app", 0 kind, app_off = "app", 0
parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size
ota0 = next(p for p in parts if p.label == "ota_0") ota0 = next(p for p in parts if p.label == "ota_0")
@@ -211,93 +208,53 @@ def inspect_image(path: Path, name: str = "", notes: str = "", origin: str = "fa
if length > ota0.size: if length > ota0.size:
raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)") raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)")
app = _app_info(data, app_off) app = _app_info(data, app_off)
return FirmwareImage(path, name or path.name, notes, origin, kind, hashlib.sha256(data).hexdigest(), return FirmwareImage(path, kind, hashlib.sha256(data).hexdigest(),
flash_size, parts, table, app_off, length, app, built_by) flash_size, parts, table, app_off, length, app)
# ------------------------------------------------------------- catalogue # ------------------------------------------------------------- catalogue
def bundled_images(manifest: Path = MANIFEST) -> list[FirmwareImage]: def folder_images(folder: Path = FIRMWARE_DIR) -> tuple[list[FirmwareImage], list[str]]:
"""Images shipped with the tool whose file exists and matches its SHA-256. """(valid images newest-compiled first, problems) for the .bin files in `folder`.
Factory images first; bundled developer builds keep their origin/built_by."""
if not manifest.exists(): A broken or foreign file is reported, never offered. The same build
return [] under two file names is offered once.
out = [] """
for e in tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", []): if not folder.is_dir():
path = manifest.parent / e["file"] return [], [f"firmware folder not found: {folder}"]
if not path.exists(): files = sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == ".bin")
continue merged: list[FirmwareImage] = []
origin = e.get("origin", "factory") app_only: list[Path] = []
if origin not in ("factory", "developer"): problems: list[str] = []
raise ImageError(f"{path.name}: unknown origin {origin!r} in the manifest") for f in files:
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), origin, built_by=e.get("built_by", "")) try:
if img.sha256 != e["sha256"].lower(): merged.append(inspect_image(f))
raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt") except ImageError as exc:
out.append(img) if "app-only" in str(exc):
return sorted(out, key=lambda i: i.origin != "factory") app_only.append(f)
else:
problems.append(f"{f.name}: {exc}")
except OSError as exc:
problems.append(f"{f.name}: cannot read ({exc})")
merged.sort(key=_newest_first)
reference = merged[0] if merged else None
images = list(merged)
for f in app_only:
try:
images.append(inspect_image(f, reference))
except (ImageError, OSError) as exc:
problems.append(f"{f.name}: {exc}")
images.sort(key=_newest_first)
unique: dict[str, FirmwareImage] = {}
for img in images:
if img.sha256 in unique:
problems.append(f"{img.name}: same file as {unique[img.sha256].name} (listed once)")
else:
unique[img.sha256] = img
return list(unique.values()), problems
def factory_images(manifest: Path = MANIFEST) -> list[FirmwareImage]: def _newest_first(img: FirmwareImage) -> tuple:
"""Only the official factory image(s).""" # Unknown compile dates last; merged before app-only for the same date.
return [i for i in bundled_images(manifest) if i.origin == "factory"] t = img.app.built_at
return (t is None, -t.timestamp() if t else 0.0, img.kind != "merged", img.name)
class FirmwareLibrary:
"""Developer builds imported by the technician (persist across tool versions)."""
def __init__(self, root: Path = LIBRARY_DIR):
self.root = root
def _index(self) -> list[dict]:
f = self.root / LIBRARY_INDEX
return json.loads(f.read_text(encoding="utf-8")) if f.exists() else []
def _save(self, entries: list[dict]) -> None:
self.root.mkdir(parents=True, exist_ok=True)
(self.root / LIBRARY_INDEX).write_text(json.dumps(entries, indent=2), encoding="utf-8")
def images(self, reference: FirmwareImage | None) -> tuple[list[FirmwareImage], list[str]]:
"""(valid images, problems) - a changed or broken file is reported, not offered."""
imgs, problems = [], []
for e in self._index():
path = self.root / e["file"]
try:
img = inspect_image(path, e.get("name", ""), e.get("notes", ""), "developer", reference,
e.get("built_by", ""))
if img.sha256 != e["sha256"]:
raise ImageError("file changed since it was imported")
imgs.append(img)
except (OSError, ImageError) as exc:
problems.append(f"{e.get('original', e['file'])}: {exc}")
return imgs, problems
def import_file(self, src: Path, reference: FirmwareImage | None, notes: str = "",
built_by: str = "") -> FirmwareImage:
img = inspect_image(src, origin="developer", reference=reference) # validate before copying
entries = self._index()
existing = next((e for e in entries if e["sha256"] == img.sha256), None)
if existing:
return inspect_image(self.root / existing["file"], existing["name"], existing["notes"],
"developer", reference, existing.get("built_by", ""))
self.root.mkdir(parents=True, exist_ok=True)
fname = f"{img.app.elf_sha[:9]}_{img.sha256[:8]}.bin"
shutil.copyfile(src, self.root / fname)
built_by = built_by.strip()
name = f"Developer build {img.app.version} ({img.app.built})" + (f", built by {built_by}" if built_by else "")
entries.append({"file": fname, "sha256": img.sha256, "name": name, "original": src.name,
"imported": dt.datetime.now().isoformat(timespec="seconds"), "notes": notes,
"built_by": built_by})
self._save(entries)
return inspect_image(self.root / fname, name, notes, "developer", reference, built_by)
def remove(self, sha256: str) -> None:
entries = self._index()
keep = [e for e in entries if e["sha256"] != sha256]
for e in entries:
if e["sha256"] == sha256:
(self.root / e["file"]).unlink(missing_ok=True)
self._save(keep)
# Backwards-compatible name used by earlier callers/tests.
official_images = factory_images
+21 -4
View File
@@ -35,8 +35,16 @@ class SetupPayload:
flow: int = 0 # standard commissioning flow flow: int = 0 # standard commissioning flow
version: int = 0 version: int = 0
from_log: bool = False # VID/PID/discriminator read from this box's log from_log: bool = False # VID/PID/discriminator read from this box's log
qr: str | None = None # exact payload reported by the box ("KOTA: matter qr=MT:...")
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: 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), fields = [(self.version, 3), (self.vendor_id, 16), (self.product_id, 16), (self.flow, 2),
(self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)] (self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)]
value, shift = 0, 0 value, shift = 0, 0
@@ -48,6 +56,8 @@ class SetupPayload:
return "MT:" + _base38(value.to_bytes(shift // 8, "little")) return "MT:" + _base38(value.to_bytes(shift // 8, "little"))
def manual_code(self) -> str: def manual_code(self) -> str:
if self.manual:
return self.manual
short = self.discriminator >> 8 # upper 4 bits short = self.discriminator >> 8 # upper 4 bits
chunk1 = short >> 2 # vid_pid_present = 0 chunk1 = short >> 2 # vid_pid_present = 0
chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF) chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF)
@@ -88,9 +98,16 @@ def verhoeff_check(digits: str) -> int:
return _INV[c] return _INV[c]
def payload_from_log(lines: list[str]) -> SetupPayload: def payload_from_log(lines: list[str], qr: str | None = None, manual: str | None = None) -> SetupPayload:
"""Use the box's advertised VID/PID/discriminator if seen; defaults otherwise.""" """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): for text in reversed(lines):
if m := _ADVERTISE.search(text): if m := _ADVERTISE.search(text):
return SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True) base = SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True)
return SetupPayload() 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
+3 -2
View File
@@ -243,8 +243,9 @@ def check_card(mount: str, filesystem: str = "") -> CardReport:
"""What the box would find on this card (values masked in the UI).""" """What the box would find on this card (values masked in the UI)."""
root = Path(mount) root = Path(mount)
problems: list[str] = [] problems: list[str] = []
fs = filesystem.upper() # Windows says "FAT32"; macOS diskutil says "DOS_FAT_32" / "Windows_FAT_32" / "MS-DOS FAT32".
if fs and "FAT32" not in fs and fs not in ("FAT", "MS-DOS FAT32", "MSDOS"): fs = filesystem.upper().replace("_", "").replace(" ", "")
if fs and "FAT32" not in fs and fs not in ("FAT", "MSDOS"):
problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.") problems.append(f"File system is {filesystem}; the box needs FAT32 - re-format the card.")
env_file = find_case_insensitive(root, ENV_NAME) env_file = find_case_insensitive(root, ENV_NAME)
env = parse_env(env_file.read_bytes()) if env_file else None env = parse_env(env_file.read_bytes()) if env_file else None
+70 -16
View File
@@ -18,7 +18,7 @@ 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"
@@ -40,6 +40,17 @@ _STATUS_BY_RULE = [
("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE), ("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY), ("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY), ("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY),
# firmware 1.0.16+ (KOTA: lines, status, error IDs)
("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
("kota.low_heap", SOFTWARE),
("kota.i2c_stuck", HARDWARE), ("kota.sensor_missing", HARDWARE), ("kota.rtc_", HARDWARE),
("kota.sd_", HARDWARE), ("status.error", HARDWARE), ("status.rtc", HARDWARE), ("status.sd", HARDWARE),
("kota.wifi_down", CONNECTIVITY), ("kota.weak_wifi", CONNECTIVITY), ("kota.no_upload", CONNECTIVITY),
("kota.ota_unregistered", CONNECTIVITY), ("kota.buffer_backlog", CONNECTIVITY), ("status.conn", CONNECTIVITY),
("kota.time_invalid", CHECK),
*((f"eid.{m}", HARDWARE) for m in ("SD", "ENV", "RTC", "BME", "SEN", "AS", "MIC", "FLK", "SPI")),
*((f"eid.{m}", CONNECTIVITY) for m in ("INF", "WIFI", "OTA", "PROV")),
("eid.", SOFTWARE),
("rtc.clock_not_running", CHECK), ("reboot.", CHECK), ("rtc.clock_not_running", CHECK), ("reboot.", CHECK),
] ]
_MAC = re.compile(r"^[0-9A-F]{12}$") _MAC = re.compile(r"^[0-9A-F]{12}$")
@@ -61,26 +72,69 @@ def service_status(report: Report) -> str:
return CHECK return CHECK
BOX_MARK = {"ok": "✔", "warn": "!", "bad": "✖", "info": "i"} # same marks as the Diagnosis panel
SD_WIFI = {True: "reads Wi-Fi settings from the SD card",
False: "does not read Wi-Fi settings from the SD card",
None: "Wi-Fi-from-SD support unknown"}
def service_note(report: Report, limit: int = NOTE_LIMIT) -> str: def service_note(report: Report, limit: int = NOTE_LIMIT) -> str:
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", """The Diagnosis panel as text: verdict, LED, firmware, box self-report,
f"Firmware: {report.firmware.describe()}", start-up progress with times, findings with details and when they were seen.
f"Expected LED: {report.led.capitalize()}"]
reached = [label for _, label, t in report.milestones if t is not None] Falls back to shorter finding details when the full text does not fit the
missing = [label for _, label, t in report.milestones if t is None] service form (consequences' explanations first, then all), then truncates.
if reached: """
lines.append("OK: " + ", ".join(reached)) for detail in ("all", "roots", "none"):
if missing: text = _note(report, detail)
lines.append("Not reached: " + ", ".join(missing)) if len(text) <= limit:
return text
return text[:limit - 20].rstrip() + "\n...(shortened)"
def _note(report: Report, detail: str) -> str:
fw = report.firmware
source = " (reported by the box)" if report.led_exact else ""
lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", "",
f"Status LED should be: {report.led_text()}{source}"]
if report.led_reason:
lines.append(f" from: {report.led_reason}")
lines += ["", f"Firmware: {fw.describe()}", f" {SD_WIFI[fw.supports_sd_wifi]}"]
if report.box:
lines += ["", "Box self-report (KOTA lines):"]
lines += [f" {BOX_MARK.get(st, '-')} {topic}: {text}" for topic, text, st in report.box]
lines += ["", "Start-up progress (current boot):"]
lines += [f" ✔ {label} ({t / 1000:.1f} s)" if t is not None else f" ○ {label}"
for _, label, t in report.milestones]
lines += ["", "Findings - root causes first:" if report.roots else "Findings: no problems detected in the log"]
for rc in report.roots: for rc in report.roots:
f = rc.finding lines += _finding(rc.finding, "", detail in ("all", "roots"))
lines.append(f"- [{f.severity.label}] {f.title}")
for c in rc.consequences: for c in rc.consequences:
lines.append(f" -> {c.title}") lines += _finding(c, " ↳ ", detail == "all")
for title, items in (("Seen in earlier boots only", report.earlier),
("Resolved during this session", report.resolved)):
if items:
lines += ["", f"{title} ({len(items)}):"]
lines += [f" - [{f.severity.label}] {f.title} - {f.seen_text()}" for f in items]
if report.technician: if report.technician:
lines.append("Technician checks:") lines += ["", "Technician checks:"]
lines += [f" * {t}" for t in report.technician[-12:]] lines += [f" * {t}" for t in report.technician[-12:]]
text = "\n".join(lines) return "\n".join(lines)
return text if len(text) <= limit else text[:limit - 20].rstrip() + "\n...(shortened)"
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: def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str:
+37 -35
View File
@@ -1,53 +1,55 @@
"""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, meaning, first action) # (swatch colour, LED name, pattern -> meaning, first action)
LED_TABLE = [ LED_TABLE = [
("#2e9e44", "Green", "Everything OK.", "Nothing to do."), ("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."),
("#8e44ad", "Purple", ("#1fc8d8", "Cyan",
"No Internet / InfluxDB or OTA server unreachable.", "breathing: not paired in Matter yet<br>fast blink: a phone is connected for pairing.",
"Check the box is commissioned in the Matter app (sticker QR code) and the " "Pair the box in the Matter app (QR sticker or the QR code in the tool)."),
"site Wi-Fi has Internet. Board reboots itself after ~2.5 min of purple."),
("#d64541", "Red",
"Sensor or hardware fault (BME688, SEN66, AS7341, microphone, flicker) "
"or failed self-check after update.",
"Connect serial - the log names the failing sensor. Check sensor cables."),
("#2f6fd6", "Blue",
"Clock (RTC DS1307) failed to start. The RTC is the first I2C device, "
"so a broken I2C cable also shows blue.",
"Check the I2C harness first, then the RTC module / coin cell."),
("#e6c229", "Yellow", "Firmware update downloading.", "Do not switch off. Wait."),
("#f08c1a", "Orange", "Self-check after an update (~2.5 min).",
"Wait; should turn green (or red on failure)."),
("#f4f4f4", "White", ("#f4f4f4", "White",
"SD card missing/unreadable or ENV.TXT incomplete - or a factory reset is " "fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>"
"in progress (Matter button held 5 s).", "breathing: Wi-Fi setup portal.",
"If nobody pressed the Matter button: the SD card is inside the case - " "SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."),
"unscrew, re-seat or re-provision it."), ("#d64541", "Red",
"1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed<br>"
"solid: other internal error / out of memory.",
"Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."),
("#2f6fd6", "Blue",
"1 pulse: clock (RTC) not responding<br>2 pulses: I2C bus stuck (shorted / broken sensor cable).",
"Check the I2C cable between the boards first, then the RTC module."),
("#8e44ad", "Purple",
"solid: no network<br>blinking: server rejected the box (wrong ENV.TXT credentials).",
"Solid: check Wi-Fi / Internet. Blinking: re-provision the SD card with the current ENV.TXT."),
("#e6c229", "Yellow", "blinking, faster over time: downloading an update.", "Do not switch off. Wait."),
("#f08c1a", "Orange", "solid: self-check after an update (~2.5 min).",
"Wait; should turn green (or red on failure)."),
("#444444", "Off", "Firmware did not start (or crashed very early).", ("#444444", "Off", "Firmware did not start (or crashed very early).",
"Check power switch & USB cable. If the small LED is also dark: re-flash."), "Check power switch & USB cable. If the small LED is also dark: re-flash."),
] ]
HINTS = [ HINTS = [
"<b>Colour keeps changing / LED goes dark every few seconds</b> = reboot loop. " "<b>Before a self-restart</b> the LED flashes the error colour 3 times; <b>after the restart</b> it "
"Connect serial and press <i>Reset &amp; capture</i>: the last lines before the " "shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press "
"reboot name the cause.", "<i>Reset &amp; capture</i>: the tool reads the restart reason from the box.",
"<b>Small LED next to the RGB LED</b> blinks once per second while the " "<b>Small LED next to the RGB LED</b> (heartbeat): blinks once per second while online, "
"firmware runs. Not blinking = firmware hung or not running.", "double-blinks while offline. Dark or steady = firmware hung or not running.",
"<b>Serial monitor</b>: lines starting with <b>KOTA:</b> are the box's own diagnosis (boot, I2C, SD, "
"clock, Matter, microphone, status, LED, and a health line every 60 s). The Diagnosis panel reads them "
"directly; filter the view with <i>KOTA diagnostics + errors</i>. Error lines carry a code like "
"<b>[E-SD-03]</b> - the tool shows the recommended action for it.",
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it " "<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
"(off = no noise measurements), and the Matter button. Holding the " "(off = no noise measurements), and the Matter button. Holding the "
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter " "Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
"network and must be commissioned again with the sticker QR code).", "network and must be commissioned again with the sticker QR code).",
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. " "<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
"Normally not needed - the tool resets the board over USB.", "Normally not needed - the tool resets the board over USB.",
"<b>Gathering messages for the first ~2 minutes</b> are normal " "<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
"(the air-quality sensor warms up for 120 s).", "by its compile date.",
] ]
STEPS = [ STEPS = [
@@ -73,10 +75,10 @@ def cheatsheet_html() -> str:
return f""" return f"""
<h2>Sensorbox quick start</h2> <h2>Sensorbox quick start</h2>
<ol>{steps}</ol> <ol>{steps}</ol>
<h3>RGB status LED (always solid colour)</h3> <h3>RGB status LED - colour and pattern</h3>
<table border='1' cellspacing='0' cellpadding='0' width='100%'> <table border='1' cellspacing='0' cellpadding='0' width='100%'>
<tr><th align='left' style='padding:6px'>LED</th> <tr><th align='left' style='padding:6px'>LED</th>
<th align='left' style='padding:6px'>Most likely meaning</th> <th align='left' style='padding:6px'>Pattern: meaning</th>
<th align='left' style='padding:6px'>First action</th></tr> <th align='left' style='padding:6px'>First action</th></tr>
{rows} {rows}
</table> </table>
+27 -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
@@ -41,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:
@@ -77,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)
@@ -103,6 +110,8 @@ class DiagnosticsPanel(QWidget):
self.qr_btn = QPushButton("Matter pairing QR code...") self.qr_btn = QPushButton("Matter pairing QR code...")
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone") self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft) lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
lay.addWidget(self.box_title)
lay.addWidget(self.box)
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"))
@@ -128,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",
@@ -137,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
@@ -218,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 "")
+45 -79
View File
@@ -1,4 +1,5 @@
"""Flash firmware tab: factory image or imported developer builds; App-only by default.""" """Flash firmware tab: only the .bin files in the tool's firmware/ folder,
chosen by their compile date; App-only by default."""
from __future__ import annotations from __future__ import annotations
@@ -6,12 +7,11 @@ import datetime as dt
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, Signal from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QButtonGroup, QButtonGroup,
QCheckBox, QCheckBox,
QFileDialog,
QInputDialog,
QComboBox, QComboBox,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
@@ -26,7 +26,8 @@ from PySide6.QtWidgets import (
) )
from ..core.session import SESSIONS_DIR from ..core.session import SESSIONS_DIR
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, bundled_images from ..diag.firmware import local_build, register_local_build, same_build
from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_images
from ..flash.plan import Mode, build_plan from ..flash.plan import Mode, build_plan
from ..flash.runner import FlashRunner, Result from ..flash.runner import FlashRunner, Result
from ..serialio.ports import PortInfo, list_serial_ports from ..serialio.ports import PortInfo, list_serial_ports
@@ -72,32 +73,32 @@ class FlashTab(QWidget):
bl.addLayout(row) bl.addLayout(row)
bl.addWidget(self.box_fw) bl.addWidget(self.box_fw)
# 2. firmware # 2. firmware - only files in the tool's firmware/ folder
fwbox = QGroupBox("2. Firmware") fwbox = QGroupBox("2. Firmware (by compile date)")
self.image_combo = QComboBox(minimumWidth=360) self.image_combo = QComboBox(minimumWidth=360)
self.import_btn = QPushButton("Import firmware file...") self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
self.import_btn.setToolTip("Add a newer build the firmware team sent you (.bin)") self.reload_btn = QPushButton("Refresh list")
self.import_btn.clicked.connect(self._import) self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)")
self.remove_btn = QPushButton("Remove from list") self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
self.remove_btn.clicked.connect(self._remove) self.folder_btn = QPushButton("Open firmware folder")
self.folder_btn.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
self.image_info = QLabel(wordWrap=True) self.image_info = QLabel(wordWrap=True)
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
fl = QVBoxLayout(fwbox) fl = QVBoxLayout(fwbox)
fl.addWidget(self.image_combo) fl.addWidget(self.image_combo)
brow = QHBoxLayout() brow = QHBoxLayout()
brow.addWidget(self.import_btn) brow.addWidget(self.reload_btn)
brow.addWidget(self.remove_btn) brow.addWidget(self.folder_btn)
brow.addStretch(1) brow.addStretch(1)
fl.addLayout(brow) fl.addLayout(brow)
fl.addWidget(self.image_info) fl.addWidget(self.image_info)
self._library = FirmwareLibrary()
self._reference: FirmwareImage | None = None
self._problems: list[str] = [] self._problems: list[str] = []
# 3. mode # 3. mode
modebox = QGroupBox("3. What to write") modebox = QGroupBox("3. What to write")
self.rb_app = QRadioButton("App only (recommended for repairs)") self.rb_app = QRadioButton("App only (recommended for repairs)")
self.rb_full = QRadioButton("Full factory image (new or completely wiped boards)") self.rb_full = QRadioButton("Full image (new or completely wiped boards)")
self.mode_group = QButtonGroup(self) self.mode_group = QButtonGroup(self)
self.mode_group.addButton(self.rb_app) self.mode_group.addButton(self.rb_app)
self.mode_group.addButton(self.rb_full) self.mode_group.addButton(self.rb_full)
@@ -167,60 +168,23 @@ class FlashTab(QWidget):
# ------------------------------------------------------------ setup # ------------------------------------------------------------ setup
def _load_images(self, select_sha: str | None = None) -> None: def _load_images(self, select_sha: str | None = None) -> None:
self._problems = [] self._images, self._problems = folder_images()
try: for img in self._images: # lets the Serial monitor show a box build's compile date
bundled = bundled_images() register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
except ImageError as exc:
bundled = []
self._problems.append(f"bundled image: {exc}")
factory = [i for i in bundled if not i.is_developer]
self._reference = factory[0] if factory else None
dev, problems = self._library.images(self._reference)
dev += [i for i in bundled if i.is_developer]
self._problems += problems
# Newest developer builds first, then the factory image.
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True)
self._images = dev + factory
self.image_combo.blockSignals(True) self.image_combo.blockSignals(True)
self.image_combo.clear() self.image_combo.clear()
for img in self._images: for img in self._images:
self.image_combo.addItem(img.label()) self.image_combo.addItem(img.label())
if not self._images: if not self._images:
self.image_combo.addItem("No firmware image available - import one") self.image_combo.addItem("No firmware file in the firmware folder")
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), None) idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0)
if idx is None: self.image_combo.setCurrentIndex(idx) # default: newest compile date
idx = len(dev) if factory else 0 # default: the factory image
self.image_combo.setCurrentIndex(idx)
self.image_combo.blockSignals(False) self.image_combo.blockSignals(False)
self._update() self._update()
def _import(self) -> None: def _selected_sha(self) -> str | None:
path, _ = QFileDialog.getOpenFileName(self, "Import firmware from the firmware team", str(Path.home()),
"Firmware (*.bin);;All files (*)")
if not path:
return
built_by, ok = QInputDialog.getText(self, "Who built this firmware?",
"Name of the developer who built this image (shown next to it):")
if not ok:
return
try:
img = self._library.import_file(Path(path), self._reference, built_by=built_by)
except (ImageError, OSError) as exc:
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}")
return
self._load_images(select_sha=img.sha256)
kind = "complete image" if img.kind == "merged" else "app only"
QMessageBox.information(
self, "Firmware imported",
f"{img.name}\nbuild {img.app.elf_sha[:9]}, {kind}\n\nFile fingerprint (SHA-256):\n{img.sha256}\n\n"
"Compare it with the fingerprint the firmware team sent you before flashing.")
def _remove(self) -> None:
img = self._selected_image() img = self._selected_image()
if img and img.is_developer and QMessageBox.question( return img.sha256 if img else None
self, "Remove firmware", f"Remove {img.name} from this computer?") == QMessageBox.StandardButton.Yes:
self._library.remove(img.sha256)
self._load_images()
def refresh_ports(self) -> None: def refresh_ports(self) -> None:
current = self.port_combo.currentData() current = self.port_combo.currentData()
@@ -251,18 +215,17 @@ class FlashTab(QWidget):
info = "" info = ""
if img: if img:
a = img.app a = img.app
origin = ("<b>Factory image</b> (bundled with the tool)" if not img.is_developer else
"<b style='color:#c77c02'>Developer build</b> - only flash builds you received "
"from the firmware team")
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only" kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
info = (f"{origin}<br>{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}, {kind}<br>" info = (f"<b>Compiled {a.built}</b> &middot; {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
f"<small>build {a.elf_sha[:9]} &middot; file SHA-256 {img.sha256[:16]}... " f"<small>file firmware/{img.name} &middot; build {a.elf_sha[:8]} &middot; "
f"&middot; checksum OK</small>" + (f"<br><small>{img.notes}</small>" if img.notes else "")) f"SHA-256 {img.sha256[:16]}... &middot; checksum OK</small>")
else:
info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>"
"and press <b>Refresh list</b>. Only files in this folder can be flashed.")
if self._problems: if self._problems:
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> " info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
+ "; ".join(self._problems) + "</span>") + "; ".join(self._problems) + "</span>")
self.image_info.setText(info) self.image_info.setText(info)
self.remove_btn.setEnabled(bool(img and img.is_developer) and self._runner is None)
# An app-only file has no bootloader/layout: Full mode is impossible. # An app-only file has no bootloader/layout: Full mode is impossible.
full_ok = bool(img and img.kind == "merged") full_ok = bool(img and img.kind == "merged")
self.rb_full.setEnabled(full_ok and self._runner is None) self.rb_full.setEnabled(full_ok and self._runner is None)
@@ -280,7 +243,7 @@ class FlashTab(QWidget):
if mode == Mode.APP_ONLY: if mode == Mode.APP_ONLY:
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"] steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
else: else:
steps += ["Write the complete factory image (~25 s)"] steps += ["Write the complete image (~25 s)"]
steps += ["Restart the box and verify the new firmware boots"] steps += ["Restart the box and verify the new firmware boots"]
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>") self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
else: else:
@@ -290,12 +253,13 @@ class FlashTab(QWidget):
fw = self.serial_tab.engine.firmware fw = self.serial_tab.engine.firmware
if not fw.elf_sha: if not fw.elf_sha:
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it." return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
msg = f"Box currently runs: {fw.describe()}" + (f", built {fw.built}" if fw.built else "") msg = f"Box currently runs: {fw.describe()}"
if not img: if not img:
return msg return msg
if fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha): if same_build(fw.elf_sha, img.app.elf_sha):
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>" return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
box_built, img_built = _parse_built(fw.built), img.app.built_at local = local_build(fw.elf_sha)
box_built, img_built = _parse_built(fw.built or (local[1] if local else None)), img.app.built_at
if box_built and img_built and img_built > box_built: if box_built and img_built and img_built > box_built:
return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>" return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>"
if box_built and img_built and img_built < box_built: if box_built and img_built and img_built < box_built:
@@ -310,9 +274,10 @@ class FlashTab(QWidget):
return return
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
else "the COMPLETE factory image (pairing and OTA registration are erased)") else "the COMPLETE image (pairing and OTA registration are erased)")
if QMessageBox.question(self, "Flash firmware", if QMessageBox.question(self, "Flash firmware",
f"Write {what}\n\nImage: {img.name}\nBox: {port.label}\n\n" f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\n"
f"File: firmware/{img.name}\nBox: {port.label}\n\n"
"Keep the box powered and the USB cable connected until it finishes.", "Keep the box powered and the USB cable connected until it finishes.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
) != QMessageBox.StandardButton.Ok: ) != QMessageBox.StandardButton.Ok:
@@ -335,7 +300,8 @@ class FlashTab(QWidget):
self._runner.start() self._runner.start()
def _set_busy(self, busy: bool) -> None: def _set_busy(self, busy: bool) -> None:
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full, self.import_btn): for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full,
self.reload_btn):
w.setEnabled(not busy) w.setEnabled(not busy)
self._update() self._update()
@@ -363,8 +329,8 @@ class FlashTab(QWidget):
self._verify_ms += self._verify_timer.interval() self._verify_ms += self._verify_timer.interval()
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS))) self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
fw = self.serial_tab.engine.firmware fw = self.serial_tab.engine.firmware
want = self._image.app.elf_sha[:9] want = self._image.app.elf_sha[:8]
done = fw.elf_sha is not None and fw.elf_sha.startswith(want) done = same_build(fw.elf_sha, self._image.app.elf_sha)
if done or self._verify_ms >= VERIFY_TIMEOUT_MS: if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
self._verify_timer.stop() self._verify_timer.stop()
self._set_busy(False) self._set_busy(False)
@@ -374,7 +340,7 @@ class FlashTab(QWidget):
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). " self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
"Check the Diagnosis panel in the Serial monitor.", "ok") "Check the Diagnosis panel in the Serial monitor.", "ok")
else: else:
seen = fw.elf_sha[:9] if fw.elf_sha else "no boot seen" seen = fw.elf_sha[:8] if fw.elf_sha else "no boot seen"
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. " self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
"Open the Serial monitor and press Reset & capture.", "warning") "Open the Serial monitor and press Reset & capture.", "warning")
+19 -10
View File
@@ -40,7 +40,9 @@ from .guide_panel import GuidePanel
from .sd_tab import SdTab from .sd_tab import SdTab
MAX_LINES = 20000 MAX_LINES = 20000
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}} # "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
"Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
COLORS = { COLORS = {
"E": "#ff6b6b", "E": "#ff6b6b",
"W": "#f5c451", "W": "#f5c451",
@@ -49,6 +51,7 @@ COLORS = {
LineKind.RESET: "#6fc3ff", LineKind.RESET: "#6fc3ff",
LineKind.PANIC: "#ff4dd2", LineKind.PANIC: "#ff4dd2",
LineKind.HOST: "#7ee07e", LineKind.HOST: "#7ee07e",
LineKind.KOTA: "#5fd7e8",
LineKind.RAW: "#a8a8a8", LineKind.RAW: "#a8a8a8",
} }
@@ -209,8 +212,9 @@ class SerialTab(QWidget):
self._manager.connect(port) self._manager.connect(port)
def matter_payload(self) -> SetupPayload: def matter_payload(self) -> SetupPayload:
"""Pairing payload from this box's log (VID/PID/discriminator), defaults otherwise.""" """Pairing payload reported by this box (KOTA: matter), from its log, or the defaults."""
return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else []) return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [],
self._engine.matter_qr, self._engine.matter_manual)
# ---- service system ------------------------------------------------- # ---- service system -------------------------------------------------
def _service_base(self) -> str: def _service_base(self) -> str:
@@ -296,19 +300,24 @@ class SerialTab(QWidget):
self._counts[line.level] += 1 self._counts[line.level] += 1
self._update_status() self._update_status()
if self._visible(line):
self._append(line)
def _visible(self, line: LogLine) -> bool:
allowed = LEVEL_FILTERS[self.filter_combo.currentText()] allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
if allowed is not None and line.kind != LineKind.HOST and not ( if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET):
line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET) return True
): if allowed == "KOTA":
return return line.kind == LineKind.KOTA or line.level == "E"
self._append(line) return line.level in allowed
def _append(self, line: LogLine) -> None: def _append(self, line: LogLine) -> None:
sb = self.view.verticalScrollBar() sb = self.view.verticalScrollBar()
at_bottom = sb.value() >= sb.maximum() - 4 at_bottom = sb.value() >= sb.maximum() - 4
fmt = QTextCharFormat() fmt = QTextCharFormat()
fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4"))) colour = COLORS.get(line.kind) if line.kind == LineKind.KOTA and line.level == "I" else None
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST): fmt.setForeground(QColor(colour or COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST) or line.record in ("boot", "status"):
fmt.setFontWeight(QFont.Weight.Bold) fmt.setFontWeight(QFont.Weight.Bold)
cur = QTextCursor(self.view.document()) cur = QTextCursor(self.view.document())
cur.movePosition(QTextCursor.MoveOperation.End) cur.movePosition(QTextCursor.MoveOperation.End)
+7 -3
View File
@@ -55,10 +55,14 @@ class MatterQrWidget(QWidget):
self.payload = payload self.payload = payload
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size)) self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
self.code.setText(f"Manual code: {payload.manual_code_pretty()}") self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
src = ("from this box's log" if payload.from_log else if payload.from_box:
"standard code of the v1.0 firmware - connect the box to confirm") 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. " self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. "
f"Discriminator {payload.discriminator} ({src}). Same as the sticker on the box.") f"{src}. Same as the sticker on the box.")
class MatterQrDialog(QDialog): class MatterQrDialog(QDialog):
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.6.0" version = "0.8.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 = [
+3 -1
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
# If Finder refuses to open this file (Google Drive drops the executable bit), run it once as:
# bash run_macos.command (or: chmod +x run_macos.command)
# Keep the virtual environment out of the (Google Drive) tool folder. # Keep the virtual environment out of the (Google Drive) tool folder.
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.6" export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.8"
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
+1 -1
View File
@@ -3,7 +3,7 @@ setlocal
cd /d "%~dp0" cd /d "%~dp0"
rem Keep the virtual environment out of the (Google Drive) tool folder. rem Keep the virtual environment out of the (Google Drive) tool folder.
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.6" set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.8"
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()
+104 -131
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,115 +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)
# ------------------------------------------- bundled developer build (v0.6)
from kota_tech.flash.image import bundled_images # noqa: E402
def test_bundled_developer_build_is_labelled_and_not_default():
imgs = bundled_images()
assert [i.origin for i in imgs] == ["factory", "developer"] # factory first
dev = imgs[1]
assert dev.built_by == "Krzysztof Kallas" and "built by Krzysztof Kallas" in dev.label()
assert dev.kind == "merged" and dev.app.elf_sha.startswith("c94ba0e9b")
assert dev.partition_table == imgs[0].partition_table # App-only safe on v1.0 boxes
assert [i.origin for i in factory_images_only()] == ["factory"]
def factory_images_only():
from kota_tech.flash.image import factory_images
return factory_images()
def test_import_records_who_built_it(image, tmp_path):
lib = FirmwareLibrary(tmp_path / "lib")
img = lib.import_file(_app_only_file(image, tmp_path), reference=image, built_by=" Anna Nowak ")
assert img.built_by == "Anna Nowak" and "built by Anna Nowak" in img.label()
again, _ = lib.images(image)
assert again[0].built_by == "Anna Nowak"
+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())
+12
View File
@@ -12,3 +12,15 @@ def test_other_ports_not_sensorbox():
bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None) bt = PortInfo("COM3", "Standard Serial over Bluetooth link", None, None, None)
assert not bt.is_sensorbox and bt.mac is None assert not bt.is_sensorbox and bt.mac is None
assert bt.label == "COM3 - Standard Serial over Bluetooth link" assert bt.label == "COM3 - Standard Serial over Bluetooth link"
def test_macos_lists_only_callout_devices(monkeypatch):
from types import SimpleNamespace
from kota_tech.serialio import ports
fake = [SimpleNamespace(device=d, description="USB JTAG/serial debug unit", vid=0x303A, pid=0x1001,
serial_number="A4:CB:8F:C9:46:F8")
for d in ("/dev/tty.usbmodem1101", "/dev/cu.usbmodem1101")]
monkeypatch.setattr(ports.sys, "platform", "darwin")
monkeypatch.setattr(ports.list_ports, "comports", lambda: fake)
assert [p.device for p in ports.list_serial_ports()] == ["/dev/cu.usbmodem1101"]
+11
View File
@@ -170,3 +170,14 @@ def test_elevated_result_folder_is_not_owner_only(tmp_path, monkeypatch):
assert work.parent == tmp_path / "tmp" and work.is_dir() assert work.parent == tmp_path / "tmp" and work.is_dir()
(work / "result.json").write_text('{"ok": true}', encoding="utf-8") (work / "result.json").write_text('{"ok": true}', encoding="utf-8")
assert (work / "result.json").read_text(encoding="utf-8") assert (work / "result.json").read_text(encoding="utf-8")
@pytest.mark.parametrize("fs", ["FAT32", "DOS_FAT_32", "Windows_FAT_32", "MS-DOS FAT32"])
def test_fat32_names_from_windows_and_macos_are_accepted(tmp_path, fs):
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
assert not any("File system" in p for p in check_card(str(tmp_path), fs).problems)
def test_exfat_is_flagged(tmp_path):
(tmp_path / "ENV.TXT").write_bytes(b"INFLUX_URL=https://x\nINFLUX_TOKEN=abc\n")
assert any("File system" in p for p in check_card(str(tmp_path), "ExFAT").problems)
+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
Generated
+1 -1
View File
@@ -250,7 +250,7 @@ wheels = [
[[package]] [[package]]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.6.0" version = "0.8.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "esptool" }, { name = "esptool" },