diff --git a/docs/CHANGES-v0.7.md b/docs/CHANGES-v0.7.md new file mode 100644 index 0000000..d575747 --- /dev/null +++ b/docs/CHANGES-v0.7.md @@ -0,0 +1,23 @@ +# Technician tool v0.7 - changes + +## Serial reading (firmware 1.0.16+, see firmware-diagnostics-what-changed.md) +- `KOTA: 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. diff --git a/kota_tech/__init__.py b/kota_tech/__init__.py index fd2bb79..2376837 100644 --- a/kota_tech/__init__.py +++ b/kota_tech/__init__.py @@ -1,3 +1,3 @@ """KOTA sensorbox technician tool.""" -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/kota_tech/core/logline.py b/kota_tech/core/logline.py index c6679f1..6967f3a 100644 --- a/kota_tech/core/logline.py +++ b/kota_tech/core/logline.py @@ -1,146 +1,202 @@ -"""Serial byte stream -> framed, cleaned, parsed log lines. - -ESP-IDF log format: " () : ", optionally -wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset -reason) has no level prefix and is classified separately, since a boot -banner is the only reliable reboot marker. -""" - -from __future__ import annotations - -import re -import time -from dataclasses import dataclass, field -from enum import Enum - -from .redact import redact - -ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") -ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$") -# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then -# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)". -BOOT_BANNER_RE = re.compile(r"^ESP-ROM:") -RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)") -PANIC_RE = re.compile( - r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|" - r"^Backtrace:|Rebooting\.\.\.$" -) - - -class LineKind(str, Enum): - LOG = "log" # ESP_LOGx line - BOOT = "boot" # ROM boot banner (start of a new boot) - RESET = "reset" # ROM reset-reason line - PANIC = "panic" # crash / abort output - RAW = "raw" # anything else (task list rows, line protocol, ...) - HOST = "host" # event generated by this tool (connect, reset, ...) - - -def host_event(text: str) -> LogLine: - return LogLine(f"--- {text} ---", LineKind.HOST) - - -@dataclass(frozen=True) -class LogLine: - text: str # cleaned + redacted - kind: LineKind - host_time: float = field(default_factory=time.time) - level: str | None = None # E/W/I/D/V for LOG lines - uptime_ms: int | None = None - tag: str | None = None - message: str | None = None - reset_reason: str | None = None - - -def clean(raw: str) -> str: - return ANSI_RE.sub("", raw).replace("\r", "").rstrip() - - -def parse_line(raw: str, host_time: float | None = None) -> LogLine: - text = redact(clean(raw)) - t = time.time() if host_time is None else host_time - if m := ESP_LOG_RE.match(text): - level, ms, tag, msg = m.groups() - return LogLine(text, LineKind.LOG, t, level, int(ms), tag.strip(), msg) - if BOOT_BANNER_RE.match(text): - return LogLine(text, LineKind.BOOT, t) - if m := RESET_REASON_RE.match(text): - return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2)) - if PANIC_RE.search(text): - return LogLine(text, LineKind.PANIC, t) - return LogLine(text, LineKind.RAW, t) - - -class LineFramer: - """Accumulates bytes and yields complete lines. - - A partial line is released after `idle_flush_s` without new bytes so - prompts / unterminated output are not held back forever. - - Lines are also split where a new log line starts in the middle - ("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores - interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old - line while no host reads, releasing it glued in front of the next live - output. After a (re)open, the fragment in front of the first such split - is stale and dropped. Timing cannot identify it, because the FIFO only - flushes when the board next prints, possibly seconds later. - """ - - def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096, - drop_first_partial: bool = False): - self._buf = bytearray() - self._last_rx = 0.0 - self.idle_flush_s = idle_flush_s - self.max_line = max_line - self._first_line_pending = drop_first_partial - - def cancel_drop(self) -> None: - self._first_line_pending = False - - def feed(self, data: bytes, now: float | None = None) -> list[str]: - now = time.monotonic() if now is None else now - if data: - self._buf.extend(data) - self._last_rx = now - out: list[str] = [] - while (i := self._buf.find(b"\n")) >= 0: - out += self._emit(self._buf[:i]) - del self._buf[: i + 1] - if len(self._buf) >= self.max_line: - out += self._emit(self._buf) - self._buf.clear() - return out - - def poll_idle(self, now: float | None = None) -> list[str]: - now = time.monotonic() if now is None else now - if self._buf and now - self._last_rx >= self.idle_flush_s: - return self.flush() - return [] - - def flush(self) -> list[str]: - if not self._buf: - return [] - out = self._emit(self._buf) - self._buf.clear() - return out - - def _emit(self, raw: bytes | bytearray) -> list[str]: - parts = split_glued(bytes(raw).decode("utf-8", errors="replace")) - if self._first_line_pending: - self._first_line_pending = False - if len(parts) > 1: - parts = parts[1:] # stale FIFO fragment from before the open - return parts - - -# Start of a log line (optionally ANSI-coloured) or ROM banner. -_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)") - - -def split_glued(text: str) -> list[str]: - # Left-to-right scan so an ANSI colour prefix stays with its line. - cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0] - if not cuts: - return [text] - bounds = [0, *cuts, len(text)] - return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b] +"""Serial byte stream -> framed, cleaned, parsed log lines. + +ESP-IDF log format: " () : ", optionally +wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset +reason) has no level prefix and is classified separately, since a boot +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 + +import re +import time +from dataclasses import dataclass, field +from enum import Enum + +from .redact import redact + +ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$") +# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then +# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)". +BOOT_BANNER_RE = re.compile(r"^ESP-ROM:") +RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)") +# "[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( + r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|" + r"^Backtrace:|Rebooting\.\.\.$" +) + + +class LineKind(str, Enum): + LOG = "log" # ESP_LOGx line + BOOT = "boot" # ROM boot banner (start of a new boot) + RESET = "reset" # ROM reset-reason line + PANIC = "panic" # crash / abort output + KOTA = "kota" # structured "KOTA: key=value ..." line + RAW = "raw" # anything else (task list rows, line protocol, ...) + HOST = "host" # event generated by this tool (connect, reset, ...) + + +def host_event(text: str) -> LogLine: + return LogLine(f"--- {text} ---", LineKind.HOST) + + +@dataclass(frozen=True) +class LogLine: + text: str # cleaned + redacted + kind: LineKind + host_time: float = field(default_factory=time.time) + level: str | None = None # E/W/I/D/V for LOG lines + uptime_ms: int | None = None + tag: str | None = None + message: 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: + return ANSI_RE.sub("", raw).replace("\r", "").rstrip() + + +def parse_line(raw: str, host_time: float | None = None) -> LogLine: + text = redact(clean(raw)) + t = time.time() if host_time is None else host_time + if m := ESP_LOG_RE.match(text): + level, ms, tag, msg = m.groups() + 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): + return LogLine(text, LineKind.BOOT, t) + if m := RESET_REASON_RE.match(text): + return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2)) + if PANIC_RE.search(text): + return LogLine(text, LineKind.PANIC, t) + return LogLine(text, LineKind.RAW, t) + + +class LineFramer: + """Accumulates bytes and yields complete lines. + + A partial line is released after `idle_flush_s` without new bytes so + prompts / unterminated output are not held back forever. + + Lines are also split where a new log line starts in the middle + ("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores + interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old + line while no host reads, releasing it glued in front of the next live + output. After a (re)open, the fragment in front of the first such split + is stale and dropped. Timing cannot identify it, because the FIFO only + flushes when the board next prints, possibly seconds later. + """ + + def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096, + drop_first_partial: bool = False): + self._buf = bytearray() + self._last_rx = 0.0 + self.idle_flush_s = idle_flush_s + self.max_line = max_line + self._first_line_pending = drop_first_partial + + def cancel_drop(self) -> None: + self._first_line_pending = False + + def feed(self, data: bytes, now: float | None = None) -> list[str]: + now = time.monotonic() if now is None else now + if data: + self._buf.extend(data) + self._last_rx = now + out: list[str] = [] + while (i := self._buf.find(b"\n")) >= 0: + out += self._emit(self._buf[:i]) + del self._buf[: i + 1] + if len(self._buf) >= self.max_line: + out += self._emit(self._buf) + self._buf.clear() + return out + + def poll_idle(self, now: float | None = None) -> list[str]: + now = time.monotonic() if now is None else now + if self._buf and now - self._last_rx >= self.idle_flush_s: + return self.flush() + return [] + + def flush(self) -> list[str]: + if not self._buf: + return [] + out = self._emit(self._buf) + self._buf.clear() + return out + + def _emit(self, raw: bytes | bytearray) -> list[str]: + parts = split_glued(bytes(raw).decode("utf-8", errors="replace")) + if self._first_line_pending: + self._first_line_pending = False + if len(parts) > 1: + parts = parts[1:] # stale FIFO fragment from before the open + return parts + + +# Start of a log line (optionally ANSI-coloured) or ROM banner. +_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)") + + +def split_glued(text: str) -> list[str]: + # Left-to-right scan so an ANSI colour prefix stays with its line. + cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0] + if not cuts: + return [text] + bounds = [0, *cuts, len(text)] + return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b] diff --git a/kota_tech/diag/engine.py b/kota_tech/diag/engine.py index bfe1d16..8c77f31 100644 --- a/kota_tech/diag/engine.py +++ b/kota_tech/diag/engine.py @@ -1,583 +1,999 @@ -"""Diagnostics engine: parsed log lines -> findings, milestones, LED, verdict. - -Line-level knowledge lives in rules.toml. This module adds what a single -line cannot express: - * boot tracking (ROM banner, reset reason, uptime drops when the banner - was missed) and classification of why the previous boot ended; - * the status-LED model (Status::set_status / set_status_conditional); - * crash capture (panic / ESP_ERROR_CHECK output); - * root-cause grouping via `explained_by` chains. - -Findings are session-wide (a reboot loop must keep its cause visible); -milestones and the LED are per boot. -""" - -from __future__ import annotations - -import re -import tomllib -from collections import Counter, deque -from dataclasses import dataclass, field -from enum import IntEnum -from pathlib import Path - -from ..core.logline import LineKind, LogLine -from .firmware import FirmwareInfo - -RULES_PATH = Path(__file__).with_name("rules.toml") -_MAC_LINE = re.compile(r"Device MAC address: ([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})") - - -class Severity(IntEnum): - IGNORE = -1 - INFO = 1 - WARNING = 2 - ERROR = 3 - CRITICAL = 4 - - @property - def label(self) -> str: - return self.name.capitalize() - - -# name -> (label, swatch colour) -LED = { - "UNKNOWN": ("Unknown", "#888888"), - "OFF": ("Off", "#444444"), - "GREEN": ("Green", "#2e9e44"), - "PURPLE": ("Purple", "#8e44ad"), - "RED": ("Red", "#d64541"), - "BLUE": ("Blue", "#2f6fd6"), - "YELLOW": ("Yellow", "#e6c229"), - "ORANGE": ("Orange", "#f08c1a"), - "WHITE": ("White", "#f4f4f4"), -} - -# (id, label, optional) - optional milestones are listed only once reached. -MILESTONES = [ - ("rom_boot", "Board booted", False), - ("app_started", "Firmware started", False), - ("sd_mounted", "SD card mounted", False), - ("env_loaded", "ENV.TXT read (InfluxDB settings)", False), - ("matter_started", "Matter started", True), - ("matter_commissioned", "Paired in Matter", True), - ("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True), - ("sensors_ready", "All sensors started", False), - ("wifi_connected", "Connected to Wi-Fi", False), - ("time_synced", "Clock set from the Internet", False), - ("data_uploaded", "Data uploaded to InfluxDB", False), - ("ota_registered", "Registered for firmware updates", False), - ("ota_checked", "Checked for firmware updates", False), - ("ota_update_started", "Firmware update started", True), - ("ota_update_done", "Firmware update installed", True), - ("ota_update_accepted", "Updated firmware accepted", True), -] - -# Findings that stop start-up; a crash/reboot right after them is their effect. -_INIT_FAILURES = [ - "rtc.init_failed", "sensor.init_failed", "sd.card_fail", "sd.mount_fail", - "sd.env_open_fail", "influx.env_missing", "influx.init_fail", -] -_NETWORK_ROOTS = [ - "influx.buffer_fail", "ota.check_failed", "net.dns_fail", - "matter.not_commissioned", "wifi.disconnected", -] -_RESET_BY_TOOL_WINDOW_S = 5.0 -_SHORT_BOOT_MS = 60_000 - - -class _Fmt(dict): - def __missing__(self, key: str) -> str: - return "?" - - -def _fmt(s: str, groups: dict[str, str]) -> str: - return s.format_map(_Fmt(groups)) if s else s - - -@dataclass(frozen=True) -class Rule: - id: str - pattern: re.Pattern[str] - severity: Severity - title: str = "" - meaning: str = "" - actions: tuple[str, ...] = () - explained_by: tuple[str, ...] = () - milestone: str | None = None - clears: tuple[str, ...] = () - led: str | None = None - led_from: str | None = None - split_by: str | None = None - min_count: int = 1 - min_uptime_ms: int = 0 - - -def load_rules(path: Path = RULES_PATH) -> list[Rule]: - data = tomllib.loads(path.read_text(encoding="utf-8")) - rules = [] - for r in data["rule"]: - rules.append(Rule( - id=r["id"], - pattern=re.compile(r["pattern"]), - severity=Severity[r["severity"].upper()], - title=r.get("title", ""), - meaning=r.get("meaning", ""), - actions=tuple(r.get("actions", ())), - explained_by=tuple(r.get("explained_by", ())), - milestone=r.get("milestone"), - clears=tuple(r.get("clears", ())), - led=r.get("led"), - led_from=r.get("led_from"), - split_by=r.get("split_by"), - min_count=int(r.get("min_count", 1)), - min_uptime_ms=int(r.get("min_uptime_ms", 0)), - )) - _validate(rules) - return rules - - -def _validate(rules: list[Rule]) -> None: - ids = [r.id for r in rules] - dupes = [i for i, n in Counter(ids).items() if n > 1] - if dupes: - raise ValueError(f"duplicate rule ids: {dupes}") - known = set(ids) - for r in rules: - for ref in (*r.explained_by, *r.clears): - if ref not in known: - raise ValueError(f"{r.id}: unknown rule id {ref!r}") - for colour in (r.led, r.led_from): - if colour and colour not in LED: - raise ValueError(f"{r.id}: unknown LED colour {colour!r}") - if r.severity != Severity.IGNORE and not r.title: - raise ValueError(f"{r.id}: visible rule needs a title") - - -@dataclass -class Finding: - key: str # rule id, or "rule id:split value" - rule_id: str - severity: Severity - title: str - meaning: str - actions: tuple[str, ...] - explained_by: tuple[str, ...] = () - min_count: int = 1 - count: int = 0 - first_uptime_ms: int | None = None - last_uptime_ms: int | None = None - last_seen: float = 0.0 - boot: int = 0 - resolved: bool = False - resolved_by: str | None = None - evidence: deque[str] = field(default_factory=lambda: deque(maxlen=3)) - - @property - def active(self) -> bool: - return self.count >= self.min_count and not self.resolved - - -@dataclass -class BootRecord: - index: int - host_time: float - explicit: bool # ROM banner seen (vs. inferred) - reset_reason: str | None = None - tool_reset: bool = False - last_uptime_ms: int = 0 - saw_log: bool = False - led_at_end: str | None = None - panic_lines: list[str] = field(default_factory=list) - recent_errors: deque[str] = field(default_factory=lambda: deque(maxlen=5)) - intentional_restart: bool = False # factory reset / setup-mode toggle - - -@dataclass -class RootCause: - finding: Finding - consequences: list[Finding] - - @property - def severity(self) -> Severity: - return max([self.finding.severity, *(c.severity for c in self.consequences)]) - - -@dataclass -class Report: - verdict: str - verdict_severity: Severity | None # None = healthy / nothing found - led: str - led_reason: str - firmware: FirmwareInfo - milestones: list[tuple[str, str, int | None]] # (id, label, uptime_ms) - roots: list[RootCause] - earlier: list[Finding] # seen only in previous boots - resolved: list[Finding] - boots: list[BootRecord] - lines: int - mid_run: bool # no boot seen yet - technician: list[str] = field(default_factory=list) # guided-check answers - - def to_text(self) -> str: - out = [f"VERDICT: {self.verdict}", - f"Expected status LED: {LED[self.led][0]}" - + (f" (last change: {self.led_reason})" if self.led_reason else ""), - f"Firmware: {self.firmware.describe()}", - f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}", - "", "Milestones (current boot):"] - for _, label, t in self.milestones: - out.append(f" [{'x' if t is not None else ' '}] {label}" - + (f" @ {t / 1000:.1f} s" if t is not None else "")) - out.append("") - out.append("Findings:" if self.roots else "Findings: none") - for rc in self.roots: - out += _finding_text(rc.finding, " ") - for c in rc.consequences: - out.append(" consequence:") - out += _finding_text(c, " ") - if self.earlier: - out.append("") - out.append("Seen in earlier boots only:") - for f in self.earlier: - out.append(f" - [{f.severity.label}] {f.title} (x{f.count})") - if self.resolved: - out.append("") - out.append("Resolved during this session:") - for f in self.resolved: - out.append(f" - {f.title}" + (f" (by: {f.resolved_by})" if f.resolved_by else "")) - if self.technician: - out.append("") - out.append("Technician checks:") - out += [f" - {t}" for t in self.technician] - return "\n".join(out) - - -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} {i}. {a}" for i, a in enumerate(f.actions, 1)] - if f.evidence: - lines.append(f"{ind} log: {f.evidence[-1]}") - return lines - - -class DiagnosticsEngine: - def __init__(self, rules: list[Rule] | None = None): - self.rules = rules if rules is not None else load_rules() - self._rule_by_id = {r.id: r for r in self.rules} - self.reset() - - # ---------------------------------------------------------------- API - def reset(self) -> None: - self.findings: dict[str, Finding] = {} - self.boots: list[BootRecord] = [BootRecord(0, 0.0, explicit=False)] - self.milestones: dict[str, int] = {} - self.led = "UNKNOWN" - self.led_reason = "" - self.firmware = FirmwareInfo() - self.lines = 0 - self._tool_reset_at: float | None = None - self._panic_tail = 0 # RAW lines after a panic line to keep - self.technician: list[str] = [] - self.mac: str | None = None # from "ID: Device MAC address: ..." at boot - self.matter_advert: str | None = None # "Advertise commission parameter vendorID=..." line - - @property - def boot(self) -> BootRecord: - return self.boots[-1] - - def feed(self, line: LogLine) -> None: - self.lines += 1 - text = line.text - if line.kind == LineKind.HOST: - if "reset issued by tool" in text: - self._tool_reset_at = line.host_time - return - if line.kind == LineKind.BOOT: - self._new_boot(line.host_time, explicit=True) - self.milestones["rom_boot"] = 0 - return - if line.kind == LineKind.RESET: - self.boot.reset_reason = 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" - return - if line.kind == LineKind.LOG and line.uptime_ms is not None: - b = self.boot - # Uptime jumped back to a fresh-boot value: the ROM banner was - # missed (port reconnect, capture started late). - if b.saw_log and line.uptime_ms + 3000 < b.last_uptime_ms and line.uptime_ms < 10_000: - self._new_boot(line.host_time, explicit=False) - b = self.boot - b.saw_log = True - b.last_uptime_ms = max(b.last_uptime_ms, line.uptime_ms) - - if line.kind == LineKind.PANIC: - self._on_panic(line) - elif self._panic_tail and line.kind == LineKind.RAW: - self.boot.panic_lines.append(text) - self._panic_tail -= 1 - self._crash_finding().evidence.append(text) - - self.firmware.observe(text) - if m := _MAC_LINE.search(text): - self.mac = m.group(1).upper() - if "Advertise commission parameter" in text: - self.matter_advert = text - matched = False - for rule in self.rules: - m = rule.pattern.search(text) - if not m: - continue - matched = True - if rule.min_uptime_ms and (line.uptime_ms or 0) < rule.min_uptime_ms: - continue - 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: - self.milestones[rule.milestone] = line.uptime_ms or 0 - if rule.led: - self._set_led(rule.led, rule.led_from, text) - for cid in rule.clears: - for f in self.findings.values(): - if f.rule_id == cid and not f.resolved: - f.resolved = True - f.resolved_by = rule.milestone or rule.id - if rule.id in ("sys.factory_reset",) or "Short press:" in text: - self.boot.intentional_restart = True - if rule.severity != Severity.IGNORE: - self._hit(rule, groups, line) - - self._stateful(line) - if line.level == "E": - self.boot.recent_errors.append(text) - if not matched and line.tag: - self._hit_other(line) - - def report(self) -> Report: - all_f = list(self.findings.values()) + self._reboot_findings() - cur = self.boot.index - active = [f for f in all_f if f.active] - current = [f for f in active if f.boot == cur] - earlier = [f for f in active if f not in current] - resolved = [f for f in all_f if f.resolved and f.count >= f.min_count] - roots = _group(current) - # 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)) - - mid_run = not any(b.explicit for b in self.boots) and self.boot.index == 0 - if self.lines == 0: - verdict, vsev = "Waiting for data from the sensorbox...", None - elif roots and roots[0].severity >= Severity.WARNING: - vsev = roots[0].severity - prefix = "Problem" if vsev >= Severity.ERROR else "Attention" - verdict = f"{prefix}: {roots[0].finding.title}" - elif "data_uploaded" in self.milestones: - verdict, vsev = "Healthy: data is reaching InfluxDB", None - else: - verdict, vsev = "No problems found so far", None - if mid_run and self.lines: - verdict += " (connected mid-run - press Reset & capture for a full check)" - - ms = [(mid, label, self.milestones.get(mid)) for mid, label, optional in MILESTONES - if not optional or mid in self.milestones] - # Reached milestones in the order they happened, then the pending ones. - ms.sort(key=lambda m: (m[2] is None, m[2] or 0)) - 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)) - - def add_confirmed(self, key: str, severity: Severity, title: str, meaning: str, - actions: tuple[str, ...]) -> Finding: - """A finding established by a guided check (technician answer + log).""" - f = self.findings.get(key) - if f is None: - f = self.findings[key] = Finding(key, key, severity, title, meaning, tuple(actions)) - f.count += 1 - f.boot = self.boot.index - f.resolved = False - f.evidence.append("confirmed by the technician during a guided check") - return f - - def note_technician(self, text: str) -> None: - self.technician.append(text) - - # ------------------------------------------------------------ internals - def _new_boot(self, host_time: float, explicit: bool) -> None: - prev = self.boot - # An inferred boot right after a banner-started boot is the same boot. - if not explicit and prev.explicit and not prev.saw_log: - return - prev.led_at_end = self.led - b = BootRecord(prev.index + 1, host_time, explicit) - if self._tool_reset_at is not None and host_time - self._tool_reset_at <= _RESET_BY_TOOL_WINDOW_S: - b.tool_reset = True - self._tool_reset_at = None - self.boots.append(b) - self.milestones = {} - # The WS2812 latches its colour through a software reset until - # Status::init() rewrites it; only a power-on turns it dark. - self.led_reason = "restarting - LED keeps its last colour until the firmware starts" - self.firmware = FirmwareInfo() - self._panic_tail = 0 - - def _set_led(self, colour: str, led_from: str | None, text: str) -> None: - if led_from and self.led != led_from: - return - self.led = colour - self.led_reason = text - - def _hit(self, rule: Rule, groups: dict[str, str], line: LogLine) -> None: - key = rule.id - if rule.split_by and rule.split_by in groups: - key = f"{rule.id}:{groups[rule.split_by]}" - f = self.findings.get(key) - if f is None: - f = self.findings[key] = Finding( - 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) - self._touch(f, line) - - def _touch(self, f: Finding, line: LogLine) -> None: - f.count += 1 - # Uptime restarts every boot, so "first seen" is per current boot. - if f.first_uptime_ms is None or f.boot != self.boot.index: - f.first_uptime_ms = line.uptime_ms - f.last_uptime_ms = line.uptime_ms - f.last_seen = line.host_time - f.boot = self.boot.index - f.evidence.append(line.text) - if f.resolved: - f.resolved, f.resolved_by = False, None # problem came back - - def _custom(self, key: str, severity: Severity, title: str, meaning: str, - actions: tuple[str, ...], explained_by: tuple[str, ...] = ()) -> Finding: - f = self.findings.get(key) - if f is None: - f = self.findings[key] = Finding(key, key, severity, title, meaning, actions, explained_by) - return f - - def _hit_other(self, line: LogLine) -> None: - f = self._custom( - f"other.{line.tag}", Severity.INFO, f"Unrecognised error messages from {line.tag}", - "These errors are not in the rule set yet; they may be harmless.", - ("If the box misbehaves, save the session log and send it to the firmware team.",)) - self._touch(f, line) - - def _crash_finding(self) -> Finding: - return self._custom( - "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.", - ("See the grouped cause; otherwise save the session log for the firmware team.",), - tuple(_INIT_FAILURES)) - - def _on_panic(self, line: LogLine) -> None: - b = self.boot - if len(b.panic_lines) < 8: - b.panic_lines.append(line.text) - self._panic_tail = 4 # file/func/expression lines follow ESP_ERROR_CHECK - f = self._crash_finding() - if f.count == 0 and b.recent_errors: - f.meaning += f" Last error before it: {b.recent_errors[-1]}" - self._touch(f, line) - - def _stateful(self, line: LogLine) -> None: - # RTC set by SNTP but reads "uninitialized" again later: the chip - # does not keep time (crystal/module fault), not just a flat cell. - synced = self.milestones.get("time_synced") - if (synced is not None and line.uptime_ms is not None - and line.uptime_ms > synced + 10_000 - and "RTC is uninitialized" in line.text): - f = self._custom( - "rtc.lost_after_sync", Severity.ERROR, - "Clock stops again after being set from the Internet", - "The clock was set from the Internet but stopped running shortly after. The RTC module or its crystal is faulty.", - ("Replace the RTC module (inside the case).",)) - self._touch(f, line) - - def _reboot_findings(self) -> list[Finding]: - """Classify why each earlier boot ended (needs the next boot's reset reason).""" - out: list[Finding] = [] - short_unexpected: list[BootRecord] = [] - for prev, nxt in zip(self.boots, self.boots[1:]): - if not prev.saw_log and prev.index == 0: - continue # nothing captured before the first boot - reason = nxt.reset_reason or "unknown" - if nxt.tool_reset or prev.intentional_restart: - continue - key, sev, title, meaning, actions, expl = None, Severity.INFO, "", "", (), () - if "USB_UART_CHIP_RESET" in reason: - 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)." - elif "POWERON" in reason: - key, title = "reboot.power", "Box was switched off and on" - meaning = "Power was interrupted (power switch, cable or supply)." - 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: - 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." - actions = ("Save the session log for the firmware team.",) - elif prev.panic_lines: - pass # covered by sys.crash - elif prev.led_at_end in ("PURPLE", "RED") and prev.last_uptime_ms >= 140_000: - 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." - actions = ("Fix the grouped cause; the restarts stop by themselves.",) - expl = tuple(_NETWORK_ROOTS) + ("sys.out_of_resources",) - else: - key, sev, title = "reboot.unexpected", Severity.WARNING, "Unexpected restart" - meaning = f"The firmware restarted (reset reason {reason})." - actions = ("Watch whether it repeats; save the session log for the firmware team.",) - if key: - out.append(self._reboot_finding(key, sev, title, meaning, actions, expl, nxt)) - if prev.last_uptime_ms < _SHORT_BOOT_MS and "POWERON" not in reason: - short_unexpected.append(prev) - if len(short_unexpected) >= 2: - avg = sum(b.last_uptime_ms for b in short_unexpected) / len(short_unexpected) / 1000 - f = self._reboot_finding( - "reboot.loop", Severity.CRITICAL, "Reboot loop", - f"The box restarted {len(short_unexpected)} times, about every {avg:.0f} s. " - "The error printed just before each restart is the cause.", - ("Fix the grouped cause below.",), tuple(_INIT_FAILURES), self.boot) - f.count = len(short_unexpected) - out.append(f) - return out - - def _reboot_finding(self, key, sev, title, meaning, actions, expl, boot: BootRecord) -> Finding: - f = Finding(key, key, sev, title, meaning, actions, expl) - f.count, f.boot, f.last_seen = 1, self.boot.index, boot.host_time - if boot.reset_reason: - f.evidence.append(f"reset reason: {boot.reset_reason}") - return f - - -def _group(findings: list[Finding]) -> list[RootCause]: - """Attach each finding to the root of its explained_by chain.""" - by_rule: dict[str, list[Finding]] = {} - for f in findings: - by_rule.setdefault(f.rule_id, []).append(f) - - def parent(f: Finding) -> Finding | None: - for rid in f.explained_by: - if by_rule.get(rid): - return by_rule[rid][0] - return None - - def root(f: Finding) -> Finding: - seen = {f.key} - while (p := parent(f)) is not None and p.key not in seen: - seen.add(p.key) - f = p - return f - - roots: dict[str, RootCause] = {} - for f in findings: - r = root(f) - rc = roots.setdefault(r.key, RootCause(r, [])) - if r is not f: - rc.consequences.append(f) - for rc in roots.values(): - rc.consequences.sort(key=lambda c: (-c.severity, c.first_uptime_ms or 0)) - return list(roots.values()) +"""Diagnostics engine: parsed log lines -> findings, milestones, LED, verdict. + +Line-level knowledge lives in rules.toml. This module adds what a single +line cannot express: + * boot tracking (ROM banner, reset reason, uptime drops when the banner + was missed) and classification of why the previous boot ended; + * the status-LED model (Status::set_status / set_status_conditional); + * crash capture (panic / ESP_ERROR_CHECK output); + * 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); +milestones and the LED are per boot. +""" + +from __future__ import annotations + +import re +import tomllib +from collections import Counter, deque +from dataclasses import dataclass, field +from enum import IntEnum +from pathlib import Path + +from ..core.logline import LineKind, LogLine +from . import kota +from .firmware import FirmwareInfo + +RULES_PATH = Path(__file__).with_name("rules.toml") +_MAC_LINE = re.compile(r"Device MAC address: ([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})") + + +class Severity(IntEnum): + IGNORE = -1 + INFO = 1 + WARNING = 2 + ERROR = 3 + CRITICAL = 4 + + @property + def label(self) -> str: + return self.name.capitalize() + + +# name -> (label, swatch colour) +LED = { + "UNKNOWN": ("Unknown", "#888888"), + "OFF": ("Off", "#444444"), + "GREEN": ("Green", "#2e9e44"), + "PURPLE": ("Purple", "#8e44ad"), + "RED": ("Red", "#d64541"), + "BLUE": ("Blue", "#2f6fd6"), + "CYAN": ("Cyan", "#1fc8d8"), + "YELLOW": ("Yellow", "#e6c229"), + "ORANGE": ("Orange", "#f08c1a"), + "WHITE": ("White", "#f4f4f4"), +} + +# (id, label, optional) - optional milestones are listed only once reached. +MILESTONES = [ + ("rom_boot", "Board booted", False), + ("app_started", "Firmware started", False), + ("sd_mounted", "SD card mounted", False), + ("env_loaded", "ENV.TXT read (InfluxDB settings)", False), + ("matter_started", "Matter started", True), + ("matter_commissioned", "Paired in Matter", 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), + ("wifi_connected", "Connected to Wi-Fi", False), + ("time_synced", "Clock set from the Internet", False), + ("clock_verified", "Clock keeps time (RTC verified)", True), + ("data_uploaded", "Data uploaded to InfluxDB", False), + ("ota_registered", "Registered for firmware updates", False), + ("ota_checked", "Checked for firmware updates", False), + ("ota_update_started", "Firmware update started", True), + ("ota_update_done", "Firmware update installed", True), + ("ota_update_accepted", "Updated firmware accepted", True), +] + +# Findings that stop start-up; a crash/reboot right after them is their effect. +_INIT_FAILURES = [ + "rtc.init_failed", "sensor.init_failed", "sd.card_fail", "sd.mount_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 = [ + "influx.buffer_fail", "ota.check_failed", "net.dns_fail", + "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 +_SHORT_BOOT_MS = 60_000 + + +class _Fmt(dict): + def __missing__(self, key: str) -> str: + return "?" + + +def _fmt(s: str, groups: dict[str, str]) -> str: + return s.format_map(_Fmt(groups)) if s else s + + +@dataclass(frozen=True) +class Rule: + id: str + pattern: re.Pattern[str] | None # None: raised from KOTA: fields + severity: Severity + title: str = "" + meaning: str = "" + actions: tuple[str, ...] = () + explained_by: tuple[str, ...] = () + milestone: str | None = None + clears: tuple[str, ...] = () + led: str | None = None + led_from: str | None = None + split_by: str | None = None + min_count: int = 1 + min_uptime_ms: int = 0 + + +def load_rules(path: Path = RULES_PATH) -> list[Rule]: + data = tomllib.loads(path.read_text(encoding="utf-8")) + rules = [] + for r in data["rule"]: + rules.append(Rule( + id=r["id"], + pattern=re.compile(r["pattern"]) if "pattern" in r else None, + severity=Severity[r["severity"].upper()], + title=r.get("title", ""), + meaning=r.get("meaning", ""), + actions=tuple(r.get("actions", ())), + explained_by=tuple(r.get("explained_by", ())), + milestone=r.get("milestone"), + clears=tuple(r.get("clears", ())), + led=r.get("led"), + led_from=r.get("led_from"), + split_by=r.get("split_by"), + min_count=int(r.get("min_count", 1)), + min_uptime_ms=int(r.get("min_uptime_ms", 0)), + )) + _validate(rules) + return rules + + +def _validate(rules: list[Rule]) -> None: + ids = [r.id for r in rules] + dupes = [i for i, n in Counter(ids).items() if n > 1] + if dupes: + raise ValueError(f"duplicate rule ids: {dupes}") + known = set(ids) + for r in rules: + for ref in (*r.explained_by, *r.clears): + # 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}") + for colour in (r.led, r.led_from): + if colour and colour not in LED: + raise ValueError(f"{r.id}: unknown LED colour {colour!r}") + if r.severity != Severity.IGNORE and not r.title: + raise ValueError(f"{r.id}: visible rule needs a title") + + +@dataclass +class Finding: + key: str # rule id, or "rule id:split value" + rule_id: str + severity: Severity + title: str + meaning: str + actions: tuple[str, ...] + explained_by: tuple[str, ...] = () + min_count: int = 1 + count: int = 0 + first_uptime_ms: int | None = None + last_uptime_ms: int | None = None + last_seen: float = 0.0 + boot: int = 0 + resolved: bool = False + resolved_by: str | None = None + 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 + def active(self) -> bool: + 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 +class BootRecord: + index: int + host_time: float + explicit: bool # ROM banner seen (vs. inferred) + reset_reason: str | None = None + tool_reset: bool = False + last_uptime_ms: int = 0 + saw_log: bool = False + led_at_end: str | None = None + 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)) + 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 +class RootCause: + finding: Finding + consequences: list[Finding] + + @property + def severity(self) -> Severity: + return max([self.finding.severity, *(c.severity for c in self.consequences)]) + + +@dataclass +class Report: + verdict: str + verdict_severity: Severity | None # None = healthy / nothing found + led: str + led_reason: str + firmware: FirmwareInfo + milestones: list[tuple[str, str, int | None]] # (id, label, uptime_ms) + roots: list[RootCause] + earlier: list[Finding] # seen only in previous boots + resolved: list[Finding] + boots: list[BootRecord] + lines: int + mid_run: bool # no boot seen yet + 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: + out = [f"VERDICT: {self.verdict}", + f"Expected status LED: {self.led_text()}" + + (f" (last change: {self.led_reason})" if self.led_reason else ""), + f"Firmware: {self.firmware.describe()}", + f"Boots seen: {max(len(self.boots) - (0 if self.boots and self.boots[0].explicit else 1), 0)}"] + 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: + out.append(f" [{'x' if t is not None else ' '}] {label}" + + (f" @ {t / 1000:.1f} s" if t is not None else "")) + out.append("") + out.append("Findings:" if self.roots else "Findings: none") + for rc in self.roots: + out += _finding_text(rc.finding, " ") + for c in rc.consequences: + out.append(" consequence:") + out += _finding_text(c, " ") + if self.earlier: + out.append("") + out.append("Seen in earlier boots only:") + for f in self.earlier: + out.append(f" - [{f.severity.label}] {f.title} (x{f.count})") + if self.resolved: + out.append("") + out.append("Resolved during this session:") + for f in self.resolved: + out.append(f" - {f.title}" + (f" (by: {f.resolved_by})" if f.resolved_by else "")) + if self.technician: + out.append("") + out.append("Technician checks:") + out += [f" - {t}" for t in self.technician] + return "\n".join(out) + + +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} {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: + lines.append(f"{ind} log: {f.evidence[-1]}") + return lines + + +class DiagnosticsEngine: + def __init__(self, rules: list[Rule] | None = None): + self.rules = rules if rules is not None else load_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() + + # ---------------------------------------------------------------- API + def reset(self) -> None: + self.findings: dict[str, Finding] = {} + self.boots: list[BootRecord] = [BootRecord(0, 0.0, explicit=False)] + self.milestones: dict[str, int] = {} + self.led = "UNKNOWN" + self.led_reason = "" + self.firmware = FirmwareInfo() + self.lines = 0 + self._tool_reset_at: float | None = None + self._panic_tail = 0 # RAW lines after a panic line to keep + self.technician: list[str] = [] + 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_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 + def boot(self) -> BootRecord: + return self.boots[-1] + + def feed(self, line: LogLine) -> None: + self.lines += 1 + text = line.text + if line.kind == LineKind.HOST: + if "reset issued by tool" in text: + self._tool_reset_at = line.host_time + return + if _BROWNOUT in text: + self.boot.brownout = True + if line.kind == LineKind.BOOT: + self._new_boot(line.host_time, explicit=True) + self.milestones["rom_boot"] = 0 + return + if line.kind == LineKind.RESET: + self.boot.reset_reason = 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" + return + if line.kind in (LineKind.LOG, LineKind.KOTA) and line.uptime_ms is not None: + b = self.boot + # Uptime jumped back to a fresh-boot value: the ROM banner was + # missed (port reconnect, capture started late). + if b.saw_log and line.uptime_ms + 3000 < b.last_uptime_ms and line.uptime_ms < 10_000: + self._new_boot(line.host_time, explicit=False) + b = self.boot + b.saw_log = True + b.last_uptime_ms = max(b.last_uptime_ms, line.uptime_ms) + + if line.kind == LineKind.PANIC: + self._on_panic(line) + elif self._panic_tail and line.kind == LineKind.RAW: + self.boot.panic_lines.append(text) + self._panic_tail -= 1 + self._crash_finding().evidence.append(text) + + if line.kind == LineKind.KOTA: + self._on_kota(line) + return + + self.firmware.observe(text) + if m := _MAC_LINE.search(text): + self.mac = m.group(1).upper() + if "Advertise commission parameter" in text: + self.matter_advert = text + matched = False + hit: list[Finding] = [] + rule_text = line.match_text + for rule in self._pattern_rules: + m = rule.pattern.search(rule_text) + if not m: + continue + matched = True + if rule.min_uptime_ms and (line.uptime_ms or 0) < rule.min_uptime_ms: + continue + 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: + self.milestones[rule.milestone] = line.uptime_ms or 0 + # 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) + for cid in rule.clears: + for f in self.findings.values(): + if f.rule_id == cid and not f.resolved: + f.resolved = True + f.resolved_by = rule.milestone or rule.id + if rule.id in ("sys.factory_reset",) or "Short press:" in text: + self.boot.intentional_restart = True + if rule.severity != Severity.IGNORE: + hit.append(self._hit(rule, groups, 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": + self.boot.recent_errors.append(text) + if not matched and line.tag and not line.error_id: + self._hit_other(line) + + def report(self) -> Report: + all_f = list(self.findings.values()) + self._reboot_findings() + cur = self.boot.index + active = [f for f in all_f if f.active] + current = [f for f in active if f.boot == cur] + earlier = [f for f in active if f not in current] + resolved = [f for f in all_f if f.resolved and f.count >= f.min_count] + roots = _group(current) + # 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)) + + 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: + verdict, vsev = "Waiting for data from the sensorbox...", None + elif roots and roots[0].severity >= Severity.WARNING: + vsev = roots[0].severity + prefix = "Problem" if vsev >= Severity.ERROR else "Attention" + verdict = f"{prefix}: {roots[0].finding.title}" + elif "data_uploaded" in self.milestones: + verdict, vsev = "Healthy: data is reaching InfluxDB", None + else: + verdict, vsev = "No problems found so far", None + if mid_run and self.lines: + verdict += " (connected mid-run - press Reset & capture for a full check)" + + ms = [(mid, label, self.milestones.get(mid)) for mid, label, optional in MILESTONES + if not optional or mid in self.milestones] + # Reached milestones in the order they happened, then the pending ones. + 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, + 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, + actions: tuple[str, ...]) -> Finding: + """A finding established by a guided check (technician answer + log).""" + f = self.findings.get(key) + if f is None: + f = self.findings[key] = Finding(key, key, severity, title, meaning, tuple(actions)) + f.count += 1 + f.boot = self.boot.index + f.resolved = False + f.evidence.append("confirmed by the technician during a guided check") + return f + + def note_technician(self, text: str) -> None: + self.technician.append(text) + + # ------------------------------------------------------------ internals + def _new_boot(self, host_time: float, explicit: bool) -> None: + prev = self.boot + # An inferred boot right after a banner-started boot is the same boot. + if not explicit and prev.explicit and not prev.saw_log: + return + prev.led_at_end = self.led + b = BootRecord(prev.index + 1, host_time, explicit) + if self._tool_reset_at is not None and host_time - self._tool_reset_at <= _RESET_BY_TOOL_WINDOW_S: + b.tool_reset = True + self._tool_reset_at = None + self.boots.append(b) + self.milestones = {} + # The WS2812 latches its colour through a software reset until + # Status::init() rewrites it; only a power-on turns it dark. + self.led_reason = "restarting - LED keeps its last colour until the firmware starts" + self.firmware = FirmwareInfo() + self._panic_tail = 0 + self._reset_led_state() + + def _set_led(self, colour: str, led_from: str | None, text: str) -> None: + if led_from and self.led != led_from: + return + self.led = colour + self.led_reason = text + + def _hit(self, rule: Rule, groups: dict[str, str], line: LogLine) -> None: + key = rule.id + if rule.split_by and rule.split_by in groups: + key = f"{rule.id}:{groups[rule.split_by]}" + f = self.findings.get(key) + if f is None: + f = self.findings[key] = Finding( + 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) + self._touch(f, line) + return f + + def _touch(self, f: Finding, line: LogLine) -> None: + f.count += 1 + # Uptime restarts every boot, so "first seen" is per current boot. + if f.first_uptime_ms is None or f.boot != self.boot.index: + f.first_uptime_ms = line.uptime_ms + f.last_uptime_ms = line.uptime_ms + f.last_seen = line.host_time + f.boot = self.boot.index + f.evidence.append(line.text) + if f.resolved: + f.resolved, f.resolved_by = False, None # problem came back + + def _custom(self, key: str, severity: Severity, title: str, meaning: str, + actions: tuple[str, ...], explained_by: tuple[str, ...] = ()) -> Finding: + f = self.findings.get(key) + if f is None: + f = self.findings[key] = Finding(key, key, severity, title, meaning, actions, explained_by) + return f + + def _hit_other(self, line: LogLine) -> None: + f = self._custom( + f"other.{line.tag}", Severity.INFO, f"Unrecognised error messages from {line.tag}", + "These errors are not in the rule set yet; they may be harmless.", + ("If the box misbehaves, save the session log and send it to the firmware team.",)) + self._touch(f, line) + + def _crash_finding(self) -> Finding: + return self._custom( + "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.", + ("See the grouped cause; otherwise save the session log for the firmware team.",), + tuple(_INIT_FAILURES) + ("reboot.brownout",)) + + def _on_panic(self, line: LogLine) -> None: + b = self.boot + if len(b.panic_lines) < 8: + b.panic_lines.append(line.text) + self._panic_tail = 4 # file/func/expression lines follow ESP_ERROR_CHECK + f = self._crash_finding() + if f.count == 0 and b.recent_errors: + f.meaning += f" Last error before it: {b.recent_errors[-1]}" + self._touch(f, line) + + def _stateful(self, line: LogLine) -> None: + # RTC set by SNTP but reads "uninitialized" again later: the chip + # does not keep time (crystal/module fault), not just a flat cell. + synced = self.milestones.get("time_synced") + if (synced is not None and line.uptime_ms is not None + and line.uptime_ms > synced + 10_000 + and "RTC is uninitialized" in line.text): + f = self._custom( + "rtc.lost_after_sync", Severity.ERROR, + "Clock stops again after being set from the Internet", + "The clock was set from the Internet but stopped running shortly after. The RTC module or its crystal is faulty.", + ("Replace the RTC module (inside the case).",)) + 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]: + """Classify why each earlier boot ended (needs the next boot's reset reason).""" + out: list[Finding] = [] + 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:]): + if not prev.saw_log and prev.index == 0: + continue # nothing captured before the first boot + reason = nxt.reset_reason or "unknown" + if nxt.tool_reset or prev.intentional_restart: + 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, "", "", (), () + 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" + 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: + key, title = "reboot.power", "Box was switched off and on" + meaning = "Power was interrupted (power switch, cable or supply)." + actions = ("If nobody switched it off: check the power supply and cable.",) + elif "WDT" in reason: + 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." + actions = ("Save the session log for the firmware team.",) + elif prev.panic_lines: + pass # covered by sys.crash + 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" + 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.",) + expl = tuple(_NETWORK_ROOTS) + ("sys.out_of_resources",) + else: + key, sev, title = "reboot.unexpected", Severity.WARNING, "Unexpected restart" + meaning = f"The firmware restarted (reset reason {reason})." + actions = ("Watch whether it repeats; save the session log for the firmware team.",) + if key: + out.append(self._reboot_finding(key, sev, title, meaning, actions, expl, nxt)) + if prev.last_uptime_ms < _SHORT_BOOT_MS and "POWERON" not in reason: + short_unexpected.append(prev) + if len(short_unexpected) >= 2: + avg = sum(b.last_uptime_ms for b in short_unexpected) / len(short_unexpected) / 1000 + f = self._reboot_finding( + "reboot.loop", Severity.CRITICAL, "Reboot loop", + f"The box restarted {len(short_unexpected)} times, about every {avg:.0f} s. " + "The error printed just before each restart is the cause.", + ("Fix the grouped cause below.",), tuple(_INIT_FAILURES) + ("reboot.brownout",), self.boot) + f.count = len(short_unexpected) + out.append(f) + # 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: + f = Finding(key, key, sev, title, meaning, actions, expl) + f.count, f.boot, f.last_seen = 1, self.boot.index, boot.host_time + if boot.reset_reason: + f.evidence.append(f"reset reason: {boot.reset_reason}") + return f + + +def _group(findings: list[Finding]) -> list[RootCause]: + """Attach each finding to the root of its explained_by chain.""" + by_rule: dict[str, list[Finding]] = {} + for f in findings: + by_rule.setdefault(f.rule_id, []).append(f) + + def parent(f: Finding) -> Finding | None: + for rid in f.explained_by: + if by_rule.get(rid): + return by_rule[rid][0] + return None + + def root(f: Finding) -> Finding: + seen = {f.key} + while (p := parent(f)) is not None and p.key not in seen: + seen.add(p.key) + f = p + return f + + roots: dict[str, RootCause] = {} + for f in findings: + r = root(f) + rc = roots.setdefault(r.key, RootCause(r, [])) + if r is not f: + rc.consequences.append(f) + for rc in roots.values(): + rc.consequences.sort(key=lambda c: (-c.severity, c.first_uptime_ms or 0)) + return list(roots.values()) diff --git a/kota_tech/diag/error_ids.csv b/kota_tech/diag/error_ids.csv new file mode 100644 index 0000000..b38ceaf --- /dev/null +++ b/kota_tech/diag/error_ids.csv @@ -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 diff --git a/kota_tech/diag/firmware.py b/kota_tech/diag/firmware.py index 3d1440f..88aad52 100644 --- a/kota_tech/diag/firmware.py +++ b/kota_tech/diag/firmware.py @@ -1,97 +1,147 @@ -"""Firmware identification from the boot log. - -The app version string is "1.0" in every build seen so far, so builds are -told apart by the ELF SHA256 prefix printed by app_init. Boxes receive new -firmware over OTA; an unknown SHA usually means a box has already updated. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, field - - -@dataclass(frozen=True) -class KnownFirmware: - name: str - source: str - sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT - setup_portal: bool # short press -> Wi-Fi setup portal - - -KNOWN: dict[str, KnownFirmware] = { - "45fa9d5b9": KnownFirmware( - name="v1.0 (built Jun 29 2026)", - source="factory image new_system.bin", - sd_wifi=False, - setup_portal=False, - ), - "c94ba0e9b": KnownFirmware( - name="v1.0 source build (Sep 26 2026)", - source="developer build, built by Krzysztof Kallas", - sd_wifi=True, - setup_portal=True, - ), -} - -_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P[0-9a-f]{6,})") -_VER = re.compile(r"app_init: App version:\s+(?P\S+)") -_BUILT = re.compile(r"app_init: Compile time:\s+(?P.+)$") -_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P\S+)") -# Evidence of features, for builds not in KNOWN. -_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ") -_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ") -# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag. -_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted") - - -@dataclass -class FirmwareInfo: - elf_sha: str | None = None - version: str | None = None - built: str | None = None - idf: str | None = None - evidence: set[str] = field(default_factory=set) - - def observe(self, text: str) -> None: - if m := _ELF.search(text): - self.elf_sha = m.group("sha") - elif m := _VER.search(text): - self.version = m.group("v") - elif m := _BUILT.search(text): - self.built = m.group("t").strip() - elif m := _IDF.search(text): - self.idf = m.group("v") - if _FEATURE_SD_WIFI.search(text): - self.evidence.add("sd_wifi") - if _FEATURE_PORTAL.search(text): - self.evidence.add("setup_portal") - if _LEGACY_SD_TAG.search(text): - self.evidence.add("legacy_v1") - - @property - def known(self) -> KnownFirmware | None: - if not self.elf_sha: - return None - return next((k for sha, k in KNOWN.items() if self.elf_sha.startswith(sha)), None) - - @property - def supports_sd_wifi(self) -> bool | None: - """True/False when determinable, None when unknown (no boot seen).""" - if k := self.known: - return k.sd_wifi - if "sd_wifi" in self.evidence: - return True - if "legacy_v1" in self.evidence: - return False - return None - - def describe(self) -> str: - if k := self.known: - return f"{k.name} - {k.source}" - if self.elf_sha: - built = f", built {self.built}" if self.built else "" - return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)" - if "legacy_v1" in self.evidence: - return "Probably v1.0 factory image (press Reset & capture to confirm)" - return "Not identified yet - press Reset & capture" +"""Firmware identification from the boot log. + +Up to firmware 1.0 the app version string was "1.0" in every build, so +builds are told apart by the ELF SHA256 prefix printed by app_init. Boxes +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 + +import re +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class KnownFirmware: + name: str + source: str + sd_wifi: bool # reads SSID/PASSWORD from ENV.TXT + setup_portal: bool # short press -> Wi-Fi setup portal + + +KNOWN: dict[str, KnownFirmware] = { + "45fa9d5b9": KnownFirmware( + name="v1.0 (built Jun 29 2026)", + source="factory image new_system.bin", + sd_wifi=False, + setup_portal=False, + ), + "c94ba0e9b": KnownFirmware( + name="v1.0 source build (Sep 26 2026)", + source="developer build, built by Krzysztof Kallas", + sd_wifi=True, + setup_portal=True, + ), +} + +# 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[0-9a-f]{6,})") +_VER = re.compile(r"app_init: App version:\s+(?P\S+)") +_BUILT = re.compile(r"app_init: Compile time:\s+(?P.+)$") +_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P\S+)") +# Evidence of features, for builds not in KNOWN. +_FEATURE_SD_WIFI = re.compile(r"WIFI_CREDENTIALS: ") +_FEATURE_PORTAL = re.compile(r"WIFI_PROVISIONING: |Short press: ") +# Deployed v1.0 logs the SD mount under the INFLUX_BUFFER tag. +_LEGACY_SD_TAG = re.compile(r"INFLUX_BUFFER: SD card mounted") + + +@dataclass +class FirmwareInfo: + elf_sha: str | None = None + version: str | None = None + built: str | None = None + idf: str | None = None + hw: str | None = None + 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: + if m := _ELF.search(text): + self.elf_sha = m.group("sha") + elif m := _VER.search(text): + self.version = m.group("v") + elif m := _BUILT.search(text): + self.built = m.group("t").strip() + elif m := _IDF.search(text): + self.idf = m.group("v") + if _FEATURE_SD_WIFI.search(text): + self.evidence.add("sd_wifi") + if _FEATURE_PORTAL.search(text): + self.evidence.add("setup_portal") + if _LEGACY_SD_TAG.search(text): + self.evidence.add("legacy_v1") + + @property + def known(self) -> KnownFirmware | None: + if not self.elf_sha: + return None + return next((k for sha, k in KNOWN.items() if same_build(self.elf_sha, sha)), None) + + @property + def supports_sd_wifi(self) -> bool | None: + """True/False when determinable, None when unknown (no boot seen).""" + if k := self.known: + return k.sd_wifi + if "sd_wifi" in self.evidence or "kota" in self.evidence: + return True + if "legacy_v1" in self.evidence: + return False + return None + + def describe(self) -> str: + if k := self.known: + return f"{k.name} - {k.source}" + if self.elf_sha and (self.version not in (None, "1.0") or local_build(self.elf_sha)): + local = local_build(self.elf_sha) + built = self.built or (local[1] if local else None) + text = f"v{self.version or (local[0] if local else '?')} (build {self.elf_sha[:8]}" + text += f", compiled {built})" if built else ")" + if self.hw: + text += f", hardware {self.hw}" + if local: + text += f" - same as firmware/{local[2]}" + return text + if self.elf_sha: + built = f", built {self.built}" if self.built else "" + return f"Unknown build {self.elf_sha[:9]}{built} (probably updated over OTA)" + if "legacy_v1" in self.evidence: + return "Probably v1.0 factory image (press Reset & capture to confirm)" + return "Not identified yet - press Reset & capture" diff --git a/kota_tech/diag/guides.py b/kota_tech/diag/guides.py index 9400ec5..cad6b4b 100644 --- a/kota_tech/diag/guides.py +++ b/kota_tech/diag/guides.py @@ -21,7 +21,7 @@ from .engine import LED, DiagnosticsEngine, Severity GUIDES_PATH = Path(__file__).with_name("guides.toml") # 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()}, "CYCLING": "Keeps changing / goes dark every few seconds"} STEP_TYPES = {"led", "question", "info", "wait", "route"} diff --git a/kota_tech/diag/guides.toml b/kota_tech/diag/guides.toml index 632f4f4..4237587 100644 --- a/kota_tech/diag/guides.toml +++ b/kota_tech/diag/guides.toml @@ -1,735 +1,740 @@ -# Guided troubleshooting: decision trees that combine technician answers -# with live log evidence (diag.guides.GuideRunner executes them). -# -# Guide: id, title, summary, recommend (rule ids / "led:" / "always"), -# start (first step id), [[guide.step]], [[guide.end]] -# Step types: -# led technician picks the LED colour; `route` maps colour -> target. -# Colours: GREEN PURPLE RED BLUE YELLOW ORANGE WHITE OFF CYCLING -# The answer is compared with the colour the firmware log implies. -# question `options` = [{label, next}] -# info instruction + Continue (`next`) -# wait optional `tool_action` ("reset"), then watch the log: -# `until` (regex) / `until_milestone`, `fail_on` (regex), -# `timeout_s`; go to on_ok / on_fail / on_timeout. -# `pass_on_timeout = true`: reaching the timeout without fail_on -# counts as ok (used for "watch for N seconds"). -# route automatic: first current root finding whose rule id is in -# `routes` decides; otherwise `default`. -# Targets: "", "end:", "guide:". -# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to). -# Text placeholders: {expected_led} {failing_sensors} {top_finding} -# End severity: ok | info | warning | error | critical - -# ================================================================= quick -[[guide]] -id = "quick" -title = "Quick check - start here" -summary = "Tell the tool what the LED shows; it picks the right checks." -recommend = ["always"] -start = "led" - -[[guide.step]] -id = "led" -type = "led" -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" } - -[[guide.step]] -id = "heartbeat" -type = "question" -text = "Is the small LED next to the RGB LED blinking about once per second?" -options = [ - { label = "Yes, blinking", next = "green_route" }, - { label = "No - steady on or off", next = "end:hung" }, -] - -[[guide.step]] -id = "green_route" -type = "route" -routes = { "rtc.clock_not_running" = "guide:clock", "mic.silent" = "guide:mic", "mic.disabled" = "guide:mic", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network" } -default = "end:healthy" - -[[guide.end]] -id = "healthy" -severity = "ok" -title = "Box looks healthy" -text = "Green LED, firmware running and no problems in the log." -actions = ["If the customer still reports missing data, keep the tool connected for 5 minutes and check that 'Data uploaded to InfluxDB' gets ticked."] - -[[guide.end]] -id = "hung" -severity = "error" -title = "Firmware is not running normally" -text = "The heartbeat LED should blink once per second whenever the firmware runs. A steady heartbeat LED means the firmware has frozen." -actions = [ - "Switch the box off and on with the power switch.", - "If it happens again: save the session log and re-flash the official firmware (Flash firmware tab, App only).", -] - -# =============================================================== network -[[guide]] -id = "network" -title = "Purple LED / no Internet" -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"] -start = "paired" - -[[guide.step]] -id = "paired" -type = "question" -skip_if_finding = "matter.not_commissioned" -skip_to = "pair_intro" -text = "Was this box already added to the customer's Matter app (Apple Home, Google Home, ...) at this site?" -options = [ - { label = "Yes, it was paired before", next = "internet" }, - { label = "No / not sure", next = "pair_intro" }, -] - -[[guide.step]] -id = "internet" -type = "question" -text = "Does the site Wi-Fi have working Internet? Check with a phone connected to the same Wi-Fi (open any website)." -options = [ - { label = "Yes, Internet works", next = "changed" }, - { label = "No Internet on the site Wi-Fi", next = "end:site_offline" }, -] - -[[guide.step]] -id = "changed" -type = "question" -text = "Was the Wi-Fi name or password changed, or the router replaced, since the box was installed?" -options = [ - { label = "Yes / possibly", next = "end:repair_needed" }, - { label = "No", next = "end:offline_unknown" }, -] - -[[guide.step]] -id = "pair_intro" -type = "info" -text = "The box is not on any Wi-Fi yet: it must be paired in Matter. You need: a phone with Bluetooth on, the customer's Matter app, the 2.4 GHz Wi-Fi password, and the QR sticker of this box. Keep the tool connected - it follows the pairing live." -next = "pair_start" - -[[guide.step]] -id = "pair_start" -type = "wait" -text = "In the Matter app choose 'Add device' and scan the QR code shown here (the same as the sticker), or type the 11-digit manual code. Keep the phone within 1 m of the box." -show_qr = true -until = 'MAIN: Commissioning session started' -timeout_s = 300 -on_ok = "pair_wifi" -on_timeout = "not_found" - -[[guide.step]] -id = "not_found" -type = "question" -text = "The box has not seen the phone connect yet. What does the app show?" -options = [ - { label = "Still searching / let me try again", next = "pair_start" }, - { label = "'Device not found' or similar", next = "end:ble_not_found" }, -] - -[[guide.step]] -id = "pair_wifi" -type = "wait" -text = "The phone is connected to the box. Follow the app: choose the 2.4 GHz Wi-Fi network and enter its password." -until = 'MAIN: Commissioning complete|MAIN: Fabric is committed' -fail_on = 'Commissioning failed, fail safe timer expired' -timeout_s = 300 -on_ok = "pair_time" -on_fail = "end:pair_failed" -on_timeout = "end:pair_failed" - -[[guide.step]] -id = "pair_time" -type = "wait" -text = "Paired! The box is joining the Wi-Fi and getting the time from the Internet..." -until_milestone = "time_synced" -timeout_s = 120 -on_ok = "pair_upload" -on_timeout = "end:joined_no_internet" - -[[guide.step]] -id = "pair_upload" -type = "wait" -text = "Online. Waiting for the first measurements to reach the database (up to 4 minutes)..." -until = 'INFLUX: HTTP request performed, status code: 2\d\d' -fail_on = 'INFLUX: Failed to perform HTTP request: (401|403|404)' -timeout_s = 240 -on_ok = "end:online" -on_fail = "end:influx_rejected" -on_timeout = "end:no_upload" - -[[guide.end]] -id = "online" -severity = "ok" -title = "Box is paired and uploading data" -text = "Pairing, Internet time and the first upload all worked." -actions = [ - "The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.", - "It should end green.", -] - -[[guide.end]] -id = "site_offline" -severity = "warning" -title = "Site network has no Internet" -text = "The box cannot work until the site Wi-Fi reaches the Internet. This is not a box fault." -actions = ["Ask the customer to fix the Internet connection, then power-cycle the box."] - -[[guide.end]] -id = "repair_needed" -severity = "warning" -title = "Box must be paired again" -text = "The box still remembers the old Wi-Fi. It needs a factory reset and new pairing." -actions = [ - "Hold the Matter button for 5 seconds (LED turns white) - this erases the old pairing.", - "Remove the box from the customer's Matter app, then run this guide again and choose 'No / not sure'.", -] - -[[guide.end]] -id = "offline_unknown" -severity = "error" -title = "Box is paired but cannot reach the Internet - cause unclear" -text = "The network works for other devices, but not for this box." -actions = [ - "Check the router does not block new devices (guest isolation, MAC filter). Box MAC is shown in the port list.", - "Move the box closer to the router to rule out weak signal.", - "If nothing helps: factory reset (hold the Matter button 5 s) and pair again.", -] - -[[guide.end]] -id = "ble_not_found" -severity = "error" -title = "Phone cannot find the box over Bluetooth" -text = "The box only accepts pairing for a limited time after start-up." -actions = [ - "Switch the box off and on to reopen the pairing window, then try again within 10 minutes.", - "Check Bluetooth and location permission are on for the Matter app.", - "If the box was paired to another app before: hold the Matter button 5 s (factory reset) first.", -] - -[[guide.end]] -id = "pair_failed" -severity = "error" -title = "Pairing did not finish" -text = "The phone connected but the box did not join the Wi-Fi." -actions = [ - "Use a 2.4 GHz network (many routers have a separate 2.4 GHz name).", - "Re-enter the Wi-Fi password carefully.", - "Switch the box off and on, then pair again.", -] - -[[guide.end]] -id = "joined_no_internet" -severity = "error" -title = "Box joined the Wi-Fi but has no Internet" -text = "Pairing worked, but the box cannot reach time or data servers." -actions = [ - "Check the network allows outgoing connections (HTTPS, NTP) for new devices.", - "Try a phone hotspot to rule out the site network.", -] - -[[guide.end]] -id = "influx_rejected" -severity = "critical" -title = "Database rejected the box (token / bucket)" -text = "The box is online but InfluxDB refused the upload. The SD card settings are wrong." -actions = ["Re-provision the SD card with the current ENV.TXT (SD card is inside the case)."] - -[[guide.end]] -id = "no_upload" -severity = "warning" -title = "Online, but no upload seen yet" -text = "Uploads happen every minute; the first one can be delayed while the air sensor warms up." -actions = ["Keep the tool connected a few more minutes and watch the Diagnosis tab."] - -# ================================================================= RTC / I2C -[[guide]] -id = "rtc_i2c" -title = "Blue LED - clock / I2C cable" -summary = "Re-seat the I2C cable and verify the start-up live." -recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors"] -start = "explain" - -[[guide.step]] -id = "explain" -type = "info" -text = "Blue means the clock chip did not answer. It is the first chip on the shared I2C cable between the boards, so a loose cable is the most common cause. This check needs the case opened." -next = "reseat" - -[[guide.step]] -id = "reseat" -type = "info" -text = "Switch the power OFF. Open the case. Unplug and firmly re-plug the I2C cable between the boards at both ends. Close the case and switch the power ON. Then press Continue." -next = "verify" - -[[guide.step]] -id = "verify" -type = "wait" -text = "Watching the start-up (up to 60 s)..." -until_milestone = "sensors_ready" -fail_on = 'MAIN: Failed to initialize RTC' -timeout_s = 60 -on_ok = "end:i2c_fixed" -on_fail = "damaged" -on_timeout = "damaged" - -[[guide.step]] -id = "damaged" -type = "question" -text = "The clock still does not answer. Is the I2C cable or its connector visibly damaged (pinched, broken wire, bent pin)?" -options = [ - { label = "Yes, damaged", next = "end:replace_cable" }, - { label = "No, looks fine", next = "end:rtc_module" }, -] - -[[guide.end]] -id = "i2c_fixed" -severity = "ok" -title = "Fixed - all sensors start again" -text = "After re-seating the cable the clock and all sensors started." -actions = ["Make sure the cable is routed without strain before closing the case."] - -[[guide.end]] -id = "replace_cable" -severity = "error" -title = "Replace the I2C cable" -text = "A damaged I2C cable stops the clock and all sensors." -actions = ["Replace the cable, then run this check again."] - -[[guide.end]] -id = "rtc_module" -severity = "error" -title = "Clock (RTC) module is probably faulty" -text = "The cable is fine but the clock chip does not answer." -actions = ["Replace the RTC module, or send the box for repair with the session log."] - -# ==================================================================== white -[[guide]] -id = "white" -title = "White LED - SD card / reset / setup mode" -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"] -start = "button" - -[[guide.step]] -id = "button" -type = "question" -text = "Did anyone hold the Matter button (outside the case) for about 5 seconds just before the LED turned white?" -options = [ - { label = "Yes", next = "end:factory_reset" }, - { label = "No / don't know", next = "portal" }, -] - -[[guide.step]] -id = "portal" -type = "question" -skip_unless_feature = "setup_portal" -skip_to = "sd_reseat" -text = "On a phone, look at the Wi-Fi list: is there a network called 'KotaSensorBox-....'?" -options = [ - { label = "Yes", next = "end:setup_mode" }, - { label = "No", next = "sd_reseat" }, -] - -[[guide.step]] -id = "sd_reseat" -type = "info" -text = "The SD card is probably not readable. This needs the case opened: switch the power OFF, open the case, push the SD card in until it clicks (or remove and re-insert it). Close the case, switch the power ON, then press Continue." -next = "sd_verify" - -[[guide.step]] -id = "sd_verify" -type = "wait" -text = "Watching the SD card start-up (up to 60 s)..." -until = 'Read INFLUX_URL from \.env file' -fail_on = 'Failed to initialize the card|Failed to mount filesystem|Failed to read INFLUX_(URL|TOKEN)|Failed to open \.env file' -timeout_s = 60 -on_ok = "end:sd_fixed" -on_fail = "end:sd_reprovision" -on_timeout = "end:sd_reprovision" - -[[guide.end]] -id = "factory_reset" -severity = "info" -title = "White = factory reset in progress" -text = "The box erased its Matter pairing and restarts." -actions = ["Pair it again in the Matter app (Purple LED guide)."] - -[[guide.end]] -id = "setup_mode" -severity = "info" -title = "White = Wi-Fi setup mode" -text = "The box is waiting for Wi-Fi settings on its own network." -actions = [ - "Connect a phone to KotaSensorBox-.... and open http://sensorbox.local/ to enter the Wi-Fi,", - "or short-press the Matter button (< 2 s) to leave setup mode.", -] - -[[guide.end]] -id = "sd_fixed" -severity = "ok" -title = "Fixed - SD card is read again" -text = "The box found the SD card and read ENV.TXT." -actions = ["Close the case and watch the Diagnosis tab for a minute."] - -[[guide.end]] -id = "sd_reprovision" -severity = "critical" -title = "SD card is unreadable or has wrong settings" -text = "Even after re-seating, the card or its ENV.TXT cannot be used." -actions = [ - "Re-provision the card with the tool (SD card tab) or use a new card.", - "The Diagnosis tab shows which step failed (card, file system or ENV.TXT keys).", -] - -# ================================================================== sensors -[[guide]] -id = "sensors" -title = "Red LED - sensor or hardware fault" -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"] -start = "which" - -[[guide.step]] -id = "which" -type = "route" -routes = { "sensor.init_failed" = "reseat", "rtc.init_failed" = "guide:rtc_i2c", "ota.selfcheck_failed" = "end:selfcheck", "sensor.selfcheck_failed" = "end:selfcheck", "sys.out_of_resources" = "end:oom" } -default = "capture" - -[[guide.step]] -id = "capture" -type = "wait" -text = "The log does not show the cause yet. The tool restarts the box and records its start-up (45 s)..." -tool_action = "reset" -timeout_s = 45 -pass_on_timeout = true -on_ok = "which_again" - -[[guide.step]] -id = "which_again" -type = "route" -routes = { "sensor.init_failed" = "reseat", "rtc.init_failed" = "guide:rtc_i2c", "sys.out_of_resources" = "end:oom" } -default = "end:red_unclear" - -[[guide.step]] -id = "reseat" -type = "info" -text = "Failing: {failing_sensors}. This needs the case opened: switch the power OFF, open the case and re-seat the cable of that sensor. Close the case, switch the power ON, then press Continue." -next = "verify" - -[[guide.step]] -id = "verify" -type = "wait" -text = "Watching the sensor start-up (up to 60 s)..." -until_milestone = "sensors_ready" -fail_on = 'MAIN: Failed to initialize' -timeout_s = 60 -on_ok = "end:sensor_fixed" -on_fail = "end:sensor_replace" -on_timeout = "end:sensor_replace" - -[[guide.end]] -id = "sensor_fixed" -severity = "ok" -title = "Fixed - all sensors start again" -text = "After re-seating, every sensor started." -actions = ["Close the case and watch the Diagnosis tab for a minute."] - -[[guide.end]] -id = "sensor_replace" -severity = "error" -title = "Sensor still fails - replace it" -text = "The sensor does not start even with a re-seated cable." -actions = ["Replace the named sensor (or its cable) and run this check again."] - -[[guide.end]] -id = "selfcheck" -severity = "error" -title = "A firmware update was rejected by its self-check" -text = "The box rolled back to its previous firmware because a sensor failed the test." -actions = ["Check the sensor named in the Diagnosis tab (inside the case), then power-cycle."] - -[[guide.end]] -id = "oom" -severity = "error" -title = "Firmware ran out of memory" -text = "Not a hardware fault; the firmware restarts itself." -actions = ["Power-cycle. If it repeats, send the session log to the firmware team."] - -[[guide.end]] -id = "red_unclear" -severity = "warning" -title = "Red LED without a clear cause in the log" -text = "The start-up did not show a failing sensor." -actions = ["Save the session log and send it to the firmware team."] - -# ============================================================= reboot loop -[[guide]] -id = "reboot_loop" -title = "LED keeps changing / box restarts" -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"] -start = "record" - -[[guide.step]] -id = "record" -type = "wait" -text = "The tool restarts the box and records 45 seconds of start-up..." -tool_action = "reset" -timeout_s = 45 -pass_on_timeout = true -on_ok = "cause" - -[[guide.step]] -id = "cause" -type = "route" -routes = { "rtc.init_failed" = "guide:rtc_i2c", "sd.card_fail" = "guide:white", "sd.mount_fail" = "guide:white", "sd.env_open_fail" = "guide:white", "influx.env_missing" = "guide:white", "influx.init_fail" = "guide:white", "sensor.init_failed" = "guide:sensors", "reboot.brownout" = "end:power_supply", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network", "influx.buffer_fail" = "guide:network" } -default = "end:loop_unclear" - -[[guide.end]] -id = "power_supply" -severity = "error" -title = "Power supply is too weak or unstable" -text = "The chip reset itself because the voltage dropped." -actions = ["Check the power supply, cable and connectors; try another supply."] - -[[guide.end]] -id = "loop_unclear" -severity = "warning" -title = "No clear restart cause in 45 s" -text = "The box did not show a known start-up error during the recording." -actions = ["Keep the tool connected for 5 minutes, then save the session log for the firmware team."] - -# ================================================================== update -[[guide]] -id = "update" -title = "Yellow / orange LED - firmware update" -summary = "Wait for the update and its self-check to finish." -recommend = ["led:YELLOW", "led:ORANGE", "ota.updating", "ota.selfcheck"] -start = "wait" - -[[guide.step]] -id = "wait" -type = "wait" -text = "A firmware update or its self-check is running. Do NOT switch the box off. Waiting up to 10 minutes..." -until = 'Passing selfcheck|OTA update successful' -fail_on = 'OTA update failed|Failing selfcheck' -timeout_s = 600 -on_ok = "end:update_ok" -on_fail = "end:update_failed" -on_timeout = "end:update_slow" - -[[guide.end]] -id = "update_ok" -severity = "ok" -title = "Update finished" -text = "The new firmware was installed or accepted." -actions = ["Wait for the green LED."] - -[[guide.end]] -id = "update_failed" -severity = "error" -title = "Update failed" -text = "The box keeps its current firmware and retries later." -actions = ["Check the Wi-Fi is stable. The Diagnosis tab shows why it failed."] - -[[guide.end]] -id = "update_slow" -severity = "warning" -title = "Update is taking very long" -text = "No end of the update was seen in 10 minutes." -actions = ["Check the Wi-Fi signal; leave the box powered and check again later."] - -# =================================================================== power -[[guide]] -id = "power" -title = "LED off" -summary = "Power switch, heartbeat LED and USB connection." -recommend = ["led:OFF"] -start = "switch" - -[[guide.step]] -id = "switch" -type = "question" -text = "Is the power switch on the outside of the case set to ON?" -options = [ - { label = "It was OFF - I switched it on", next = "boot" }, - { label = "It is ON", next = "heartbeat" }, -] - -[[guide.step]] -id = "boot" -type = "wait" -text = "Waiting for the box to start (up to 30 s)..." -until_milestone = "app_started" -timeout_s = 30 -on_ok = "end:was_off" -on_timeout = "heartbeat" - -[[guide.step]] -id = "heartbeat" -type = "question" -text = "Is the small LED next to the RGB LED blinking?" -options = [ - { label = "Yes, blinking", next = "end:rgb_led_fault" }, - { label = "No", next = "end:no_power" }, -] - -[[guide.end]] -id = "was_off" -severity = "ok" -title = "Box was switched off" -text = "It starts normally after switching on." -actions = ["Run the Quick check again once the LED shows a colour."] - -[[guide.end]] -id = "rgb_led_fault" -severity = "warning" -title = "Firmware runs, but the RGB LED stays dark" -text = "The heartbeat LED shows the firmware is running; the RGB LED or its wiring is faulty." -actions = ["Use the Diagnosis tab instead of the LED; replace the LED board when convenient."] - -[[guide.end]] -id = "no_power" -severity = "error" -title = "Box does not start" -text = "Neither LED is lit: no power or the firmware does not start." -actions = [ - "Check the power supply and cable.", - "If the USB port shows up in this tool, re-flash the official firmware (Flash firmware tab, App only).", -] - -# =================================================================== clock -[[guide]] -id = "clock" -title = "Clock not set" -summary = "Why readings are on hold and when the coin cell matters." -recommend = ["rtc.clock_not_running", "rtc.lost_after_sync"] -start = "internet" - -[[guide.step]] -id = "internet" -type = "question" -text = "The box clock is not set, so readings are on hold. Will the box have Internet at its installation site?" -options = [ - { label = "Yes", next = "end:clock_auto" }, - { label = "No / not yet", next = "end:clock_offline" }, -] - -[[guide.end]] -id = "clock_auto" -severity = "info" -title = "Clock will be set automatically" -text = "On its first Internet connection the box sets its clock and starts recording." -actions = ["If the clock is lost again after every power-off, replace the RTC coin cell (inside the case)."] - -[[guide.end]] -id = "clock_offline" -severity = "error" -title = "Box will not record data without Internet" -text = "Without the Internet the clock cannot be set, and the firmware holds all readings until it is." -actions = [ - "Give the box Internet access (pair it in Matter).", - "Replace the RTC coin cell (inside the case) so the time survives power-offs.", -] - -# ===================================================================== mic -[[guide]] -id = "mic" -title = "Microphone check" -summary = "Switched-off or silent microphone." -recommend = ["mic.disabled", "mic.silent", "mic.read_fail"] -start = "which" - -[[guide.step]] -id = "which" -type = "route" -routes = { "mic.disabled" = "switch", "mic.silent" = "clap", "mic.read_fail" = "end:mic_faulty" } -default = "clap" - -[[guide.step]] -id = "switch" -type = "question" -text = "The box reads its microphone switch as OFF. The switch is on the back of the case, just above the power switch (no need to open the box). Should this box measure noise?" -options = [ - { label = "Yes - I flipped the microphone switch", next = "switch_verify" }, - { label = "No - it is meant to be off", next = "end:mic_off_ok" }, -] - -[[guide.step]] -id = "switch_verify" -type = "wait" -# The firmware re-reads the switch before every reading (~10 s): no restart needed. -text = "Waiting for the next microphone reading (up to 30 s)..." -until = 'MICROPHONE: Read \d+ bytes' -fail_on = 'Microphone is in shutdown state' -timeout_s = 30 -on_ok = "end:mic_on" -on_fail = "flip_back" -on_timeout = "flip_back" - -[[guide.step]] -id = "flip_back" -type = "question" -text = "The box still reads the switch as OFF. Flip the microphone switch to the other position (it may have been ON already)." -options = [ - { label = "Done - I flipped it to the other position", next = "switch_verify2" }, - { label = "Leave it off", next = "end:mic_off_ok" }, -] - -[[guide.step]] -id = "switch_verify2" -type = "wait" -text = "Waiting for the next microphone reading (up to 30 s)..." -until = 'MICROPHONE: Read \d+ bytes' -fail_on = 'Microphone is in shutdown state' -timeout_s = 30 -on_ok = "end:mic_on" -on_fail = "end:mic_switch_faulty" -on_timeout = "end:mic_switch_faulty" - -[[guide.step]] -id = "clap" -type = "wait" -text = "Check the microphone switch (back of the case, above the power switch) is ON. Then clap your hands or speak loudly right next to the box for about 20 seconds..." -until = 'dB peak: (?!-inf)' -timeout_s = 45 -on_ok = "end:mic_ok" -on_timeout = "end:mic_faulty" - -[[guide.end]] -id = "mic_on" -severity = "ok" -title = "Microphone is on and recording" -text = "The firmware reads audio samples again." -actions = ["Leave the microphone switch in this position."] - -[[guide.end]] -id = "mic_off_ok" -severity = "info" -title = "Microphone intentionally off" -text = "No noise data is recorded for this box; everything else works." -actions = [] - -[[guide.end]] -id = "mic_switch_faulty" -severity = "warning" -title = "Microphone switch has no effect" -text = "The box reads the microphone as OFF in both switch positions (enable line GPIO3)." -actions = ["Check the switch and its cable to the main board (inside the case); replace the switch if needed."] - -[[guide.end]] -id = "mic_ok" -severity = "ok" -title = "Microphone works" -text = "It picked up sound - the earlier silence was just a very quiet room." -actions = [] - -[[guide.end]] -id = "mic_faulty" -severity = "error" -title = "Microphone records nothing" -text = "Even loud noise next to the box gives no signal, although the switch is on." -actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."] +# Guided troubleshooting: decision trees that combine technician answers +# with live log evidence (diag.guides.GuideRunner executes them). +# +# Guide: id, title, summary, recommend (rule ids / "led:" / "always"), +# start (first step id), [[guide.step]], [[guide.end]] +# Step types: +# led technician picks the LED colour; `route` maps colour -> target. +# Colours: GREEN PURPLE RED BLUE YELLOW ORANGE WHITE OFF CYCLING +# The answer is compared with the colour the firmware log implies. +# question `options` = [{label, next}] +# info instruction + Continue (`next`) +# wait optional `tool_action` ("reset"), then watch the log: +# `until` (regex) / `until_milestone`, `fail_on` (regex), +# `timeout_s`; go to on_ok / on_fail / on_timeout. +# `pass_on_timeout = true`: reaching the timeout without fail_on +# counts as ok (used for "watch for N seconds"). +# route automatic: first current root finding whose rule id is in +# `routes` decides; otherwise `default`. +# Targets: "", "end:", "guide:". +# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to). +# Text placeholders: {expected_led} {failing_sensors} {top_finding} +# End severity: ok | info | warning | error | critical + +# ================================================================= quick +[[guide]] +id = "quick" +title = "Quick check - start here" +summary = "Tell the tool what the LED shows; it picks the right checks." +recommend = ["always"] +start = "led" + +[[guide.step]] +id = "led" +type = "led" +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", CYAN = "guide:network", OFF = "guide:power", CYCLING = "guide:reboot_loop" } + +[[guide.step]] +id = "heartbeat" +type = "question" +text = "Is the small LED next to the RGB LED blinking about once per second?" +options = [ + { label = "Yes, blinking", next = "green_route" }, + { label = "No - steady on or off", next = "end:hung" }, +] + +[[guide.step]] +id = "green_route" +type = "route" +routes = { "rtc.clock_not_running" = "guide:clock", "mic.silent" = "guide:mic", "mic.disabled" = "guide:mic", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network" } +default = "end:healthy" + +[[guide.end]] +id = "healthy" +severity = "ok" +title = "Box looks healthy" +text = "Green LED, firmware running and no problems in the log." +actions = ["If the customer still reports missing data, keep the tool connected for 5 minutes and check that 'Data uploaded to InfluxDB' gets ticked."] + +[[guide.end]] +id = "hung" +severity = "error" +title = "Firmware is not running normally" +text = "The heartbeat LED should blink once per second whenever the firmware runs. A steady heartbeat LED means the firmware has frozen." +actions = [ + "Switch the box off and on with the power switch.", + "If it happens again: save the session log and re-flash the official firmware (Flash firmware tab, App only).", +] + +# =============================================================== network +[[guide]] +id = "network" +title = "Purple LED / no Internet" +summary = "Pairing in Matter, site Wi-Fi and Internet checks, with live confirmation." +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" + +[[guide.step]] +id = "paired" +type = "question" +skip_if_finding = "matter.not_commissioned" +skip_to = "pair_intro" +text = "Was this box already added to the customer's Matter app (Apple Home, Google Home, ...) at this site?" +options = [ + { label = "Yes, it was paired before", next = "internet" }, + { label = "No / not sure", next = "pair_intro" }, +] + +[[guide.step]] +id = "internet" +type = "question" +text = "Does the site Wi-Fi have working Internet? Check with a phone connected to the same Wi-Fi (open any website)." +options = [ + { label = "Yes, Internet works", next = "changed" }, + { label = "No Internet on the site Wi-Fi", next = "end:site_offline" }, +] + +[[guide.step]] +id = "changed" +type = "question" +text = "Was the Wi-Fi name or password changed, or the router replaced, since the box was installed?" +options = [ + { label = "Yes / possibly", next = "end:repair_needed" }, + { label = "No", next = "end:offline_unknown" }, +] + +[[guide.step]] +id = "pair_intro" +type = "info" +text = "The box is not on any Wi-Fi yet: it must be paired in Matter. You need: a phone with Bluetooth on, the customer's Matter app, the 2.4 GHz Wi-Fi password, and the QR sticker of this box. Keep the tool connected - it follows the pairing live." +next = "pair_start" + +[[guide.step]] +id = "pair_start" +type = "wait" +text = "In the Matter app choose 'Add device' and scan the QR code shown here (the same as the sticker), or type the 11-digit manual code. Keep the phone within 1 m of the box." +show_qr = true +until = 'MAIN: Commissioning session started' +timeout_s = 300 +on_ok = "pair_wifi" +on_timeout = "not_found" + +[[guide.step]] +id = "not_found" +type = "question" +text = "The box has not seen the phone connect yet. What does the app show?" +options = [ + { label = "Still searching / let me try again", next = "pair_start" }, + { label = "'Device not found' or similar", next = "end:ble_not_found" }, +] + +[[guide.step]] +id = "pair_wifi" +type = "wait" +text = "The phone is connected to the box. Follow the app: choose the 2.4 GHz Wi-Fi network and enter its password." +until = 'MAIN: Commissioning complete|MAIN: Fabric is committed' +fail_on = 'Commissioning failed, fail safe timer expired' +timeout_s = 300 +on_ok = "pair_time" +on_fail = "end:pair_failed" +on_timeout = "end:pair_failed" + +[[guide.step]] +id = "pair_time" +type = "wait" +text = "Paired! The box is joining the Wi-Fi and getting the time from the Internet..." +until_milestone = "time_synced" +timeout_s = 120 +on_ok = "pair_upload" +on_timeout = "end:joined_no_internet" + +[[guide.step]] +id = "pair_upload" +type = "wait" +text = "Online. Waiting for the first measurements to reach the database (up to 4 minutes)..." +until = 'INFLUX: HTTP request performed, status code: 2\d\d' +fail_on = 'INFLUX: Failed to perform HTTP request: (401|403|404)' +timeout_s = 240 +on_ok = "end:online" +on_fail = "end:influx_rejected" +on_timeout = "end:no_upload" + +[[guide.end]] +id = "online" +severity = "ok" +title = "Box is paired and uploading data" +text = "Pairing, Internet time and the first upload all worked." +actions = [ + "The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.", + "It should end green.", +] + +[[guide.end]] +id = "site_offline" +severity = "warning" +title = "Site network has no Internet" +text = "The box cannot work until the site Wi-Fi reaches the Internet. This is not a box fault." +actions = ["Ask the customer to fix the Internet connection, then power-cycle the box."] + +[[guide.end]] +id = "repair_needed" +severity = "warning" +title = "Box must be paired again" +text = "The box still remembers the old Wi-Fi. It needs a factory reset and new pairing." +actions = [ + "Hold the Matter button for 5 seconds (LED turns white) - this erases the old pairing.", + "Remove the box from the customer's Matter app, then run this guide again and choose 'No / not sure'.", +] + +[[guide.end]] +id = "offline_unknown" +severity = "error" +title = "Box is paired but cannot reach the Internet - cause unclear" +text = "The network works for other devices, but not for this box." +actions = [ + "Check the router does not block new devices (guest isolation, MAC filter). Box MAC is shown in the port list.", + "Move the box closer to the router to rule out weak signal.", + "If nothing helps: factory reset (hold the Matter button 5 s) and pair again.", +] + +[[guide.end]] +id = "ble_not_found" +severity = "error" +title = "Phone cannot find the box over Bluetooth" +text = "The box only accepts pairing for a limited time after start-up." +actions = [ + "Switch the box off and on to reopen the pairing window, then try again within 10 minutes.", + "Check Bluetooth and location permission are on for the Matter app.", + "If the box was paired to another app before: hold the Matter button 5 s (factory reset) first.", +] + +[[guide.end]] +id = "pair_failed" +severity = "error" +title = "Pairing did not finish" +text = "The phone connected but the box did not join the Wi-Fi." +actions = [ + "Use a 2.4 GHz network (many routers have a separate 2.4 GHz name).", + "Re-enter the Wi-Fi password carefully.", + "Switch the box off and on, then pair again.", +] + +[[guide.end]] +id = "joined_no_internet" +severity = "error" +title = "Box joined the Wi-Fi but has no Internet" +text = "Pairing worked, but the box cannot reach time or data servers." +actions = [ + "Check the network allows outgoing connections (HTTPS, NTP) for new devices.", + "Try a phone hotspot to rule out the site network.", +] + +[[guide.end]] +id = "influx_rejected" +severity = "critical" +title = "Database rejected the box (token / bucket)" +text = "The box is online but InfluxDB refused the upload. The SD card settings are wrong." +actions = ["Re-provision the SD card with the current ENV.TXT (SD card is inside the case)."] + +[[guide.end]] +id = "no_upload" +severity = "warning" +title = "Online, but no upload seen yet" +text = "Uploads happen every minute; the first one can be delayed while the air sensor warms up." +actions = ["Keep the tool connected a few more minutes and watch the Diagnosis tab."] + +# ================================================================= RTC / I2C +[[guide]] +id = "rtc_i2c" +title = "Blue LED - clock / I2C cable" +summary = "Re-seat the I2C cable and verify the start-up live." +recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors", "kota.i2c_stuck", "kota.rtc_read_failed", "status.rtc.1", "status.rtc.2"] +start = "explain" + +[[guide.step]] +id = "explain" +type = "info" +text = "Blue means the clock chip did not answer. It is the first chip on the shared I2C cable between the boards, so a loose cable is the most common cause. This check needs the case opened." +next = "reseat" + +[[guide.step]] +id = "reseat" +type = "info" +text = "Switch the power OFF. Open the case. Unplug and firmly re-plug the I2C cable between the boards at both ends. Close the case and switch the power ON. Then press Continue." +next = "verify" + +[[guide.step]] +id = "verify" +type = "wait" +text = "Watching the start-up (up to 60 s)..." +until_milestone = "sensors_ready" +fail_on = 'MAIN: Failed to initialize RTC' +timeout_s = 60 +on_ok = "end:i2c_fixed" +on_fail = "damaged" +on_timeout = "damaged" + +[[guide.step]] +id = "damaged" +type = "question" +text = "The clock still does not answer. Is the I2C cable or its connector visibly damaged (pinched, broken wire, bent pin)?" +options = [ + { label = "Yes, damaged", next = "end:replace_cable" }, + { label = "No, looks fine", next = "end:rtc_module" }, +] + +[[guide.end]] +id = "i2c_fixed" +severity = "ok" +title = "Fixed - all sensors start again" +text = "After re-seating the cable the clock and all sensors started." +actions = ["Make sure the cable is routed without strain before closing the case."] + +[[guide.end]] +id = "replace_cable" +severity = "error" +title = "Replace the I2C cable" +text = "A damaged I2C cable stops the clock and all sensors." +actions = ["Replace the cable, then run this check again."] + +[[guide.end]] +id = "rtc_module" +severity = "error" +title = "Clock (RTC) module is probably faulty" +text = "The cable is fine but the clock chip does not answer." +actions = ["Replace the RTC module, or send the box for repair with the session log."] + +# ==================================================================== white +[[guide]] +id = "white" +title = "White LED - SD card / reset / setup mode" +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", + "kota.sd_unmounted", "status.sd", "status.portal", "status.reset"] +start = "button" + +[[guide.step]] +id = "button" +type = "question" +text = "Did anyone hold the Matter button (outside the case) for about 5 seconds just before the LED turned white?" +options = [ + { label = "Yes", next = "end:factory_reset" }, + { label = "No / don't know", next = "portal" }, +] + +[[guide.step]] +id = "portal" +type = "question" +skip_unless_feature = "setup_portal" +skip_to = "sd_reseat" +text = "On a phone, look at the Wi-Fi list: is there a network called 'KotaSensorBox-....'?" +options = [ + { label = "Yes", next = "end:setup_mode" }, + { label = "No", next = "sd_reseat" }, +] + +[[guide.step]] +id = "sd_reseat" +type = "info" +text = "The SD card is probably not readable. This needs the case opened: switch the power OFF, open the case, push the SD card in until it clicks (or remove and re-insert it). Close the case, switch the power ON, then press Continue." +next = "sd_verify" + +[[guide.step]] +id = "sd_verify" +type = "wait" +text = "Watching the SD card start-up (up to 60 s)..." +until = 'Read INFLUX_URL from \.env file' +fail_on = 'Failed to initialize the card|Failed to mount filesystem|Failed to read INFLUX_(URL|TOKEN)|Failed to open \.env file' +timeout_s = 60 +on_ok = "end:sd_fixed" +on_fail = "end:sd_reprovision" +on_timeout = "end:sd_reprovision" + +[[guide.end]] +id = "factory_reset" +severity = "info" +title = "White = factory reset in progress" +text = "The box erased its Matter pairing and restarts." +actions = ["Pair it again in the Matter app (Purple LED guide)."] + +[[guide.end]] +id = "setup_mode" +severity = "info" +title = "White = Wi-Fi setup mode" +text = "The box is waiting for Wi-Fi settings on its own network." +actions = [ + "Connect a phone to KotaSensorBox-.... and open http://sensorbox.local/ to enter the Wi-Fi,", + "or short-press the Matter button (< 2 s) to leave setup mode.", +] + +[[guide.end]] +id = "sd_fixed" +severity = "ok" +title = "Fixed - SD card is read again" +text = "The box found the SD card and read ENV.TXT." +actions = ["Close the case and watch the Diagnosis tab for a minute."] + +[[guide.end]] +id = "sd_reprovision" +severity = "critical" +title = "SD card is unreadable or has wrong settings" +text = "Even after re-seating, the card or its ENV.TXT cannot be used." +actions = [ + "Re-provision the card with the tool (SD card tab) or use a new card.", + "The Diagnosis tab shows which step failed (card, file system or ENV.TXT keys).", +] + +# ================================================================== sensors +[[guide]] +id = "sensors" +title = "Red LED - sensor or hardware fault" +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", + "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" + +[[guide.step]] +id = "which" +type = "route" +routes = { "sensor.init_failed" = "reseat", "rtc.init_failed" = "guide:rtc_i2c", "ota.selfcheck_failed" = "end:selfcheck", "sensor.selfcheck_failed" = "end:selfcheck", "sys.out_of_resources" = "end:oom" } +default = "capture" + +[[guide.step]] +id = "capture" +type = "wait" +text = "The log does not show the cause yet. The tool restarts the box and records its start-up (45 s)..." +tool_action = "reset" +timeout_s = 45 +pass_on_timeout = true +on_ok = "which_again" + +[[guide.step]] +id = "which_again" +type = "route" +routes = { "sensor.init_failed" = "reseat", "rtc.init_failed" = "guide:rtc_i2c", "sys.out_of_resources" = "end:oom" } +default = "end:red_unclear" + +[[guide.step]] +id = "reseat" +type = "info" +text = "Failing: {failing_sensors}. This needs the case opened: switch the power OFF, open the case and re-seat the cable of that sensor. Close the case, switch the power ON, then press Continue." +next = "verify" + +[[guide.step]] +id = "verify" +type = "wait" +text = "Watching the sensor start-up (up to 60 s)..." +until_milestone = "sensors_ready" +fail_on = 'MAIN: Failed to initialize' +timeout_s = 60 +on_ok = "end:sensor_fixed" +on_fail = "end:sensor_replace" +on_timeout = "end:sensor_replace" + +[[guide.end]] +id = "sensor_fixed" +severity = "ok" +title = "Fixed - all sensors start again" +text = "After re-seating, every sensor started." +actions = ["Close the case and watch the Diagnosis tab for a minute."] + +[[guide.end]] +id = "sensor_replace" +severity = "error" +title = "Sensor still fails - replace it" +text = "The sensor does not start even with a re-seated cable." +actions = ["Replace the named sensor (or its cable) and run this check again."] + +[[guide.end]] +id = "selfcheck" +severity = "error" +title = "A firmware update was rejected by its self-check" +text = "The box rolled back to its previous firmware because a sensor failed the test." +actions = ["Check the sensor named in the Diagnosis tab (inside the case), then power-cycle."] + +[[guide.end]] +id = "oom" +severity = "error" +title = "Firmware ran out of memory" +text = "Not a hardware fault; the firmware restarts itself." +actions = ["Power-cycle. If it repeats, send the session log to the firmware team."] + +[[guide.end]] +id = "red_unclear" +severity = "warning" +title = "Red LED without a clear cause in the log" +text = "The start-up did not show a failing sensor." +actions = ["Save the session log and send it to the firmware team."] + +# ============================================================= reboot loop +[[guide]] +id = "reboot_loop" +title = "LED keeps changing / box restarts" +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", + "reboot.crash", "reboot.status_watchdog", "reboot.status_oom_watchdog"] +start = "record" + +[[guide.step]] +id = "record" +type = "wait" +text = "The tool restarts the box and records 45 seconds of start-up..." +tool_action = "reset" +timeout_s = 45 +pass_on_timeout = true +on_ok = "cause" + +[[guide.step]] +id = "cause" +type = "route" +routes = { "rtc.init_failed" = "guide:rtc_i2c", "sd.card_fail" = "guide:white", "sd.mount_fail" = "guide:white", "sd.env_open_fail" = "guide:white", "influx.env_missing" = "guide:white", "influx.init_fail" = "guide:white", "sensor.init_failed" = "guide:sensors", "reboot.brownout" = "end:power_supply", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network", "influx.buffer_fail" = "guide:network" } +default = "end:loop_unclear" + +[[guide.end]] +id = "power_supply" +severity = "error" +title = "Power supply is too weak or unstable" +text = "The chip reset itself because the voltage dropped." +actions = ["Check the power supply, cable and connectors; try another supply."] + +[[guide.end]] +id = "loop_unclear" +severity = "warning" +title = "No clear restart cause in 45 s" +text = "The box did not show a known start-up error during the recording." +actions = ["Keep the tool connected for 5 minutes, then save the session log for the firmware team."] + +# ================================================================== update +[[guide]] +id = "update" +title = "Yellow / orange LED - firmware update" +summary = "Wait for the update and its self-check to finish." +recommend = ["led:YELLOW", "led:ORANGE", "ota.updating", "ota.selfcheck"] +start = "wait" + +[[guide.step]] +id = "wait" +type = "wait" +text = "A firmware update or its self-check is running. Do NOT switch the box off. Waiting up to 10 minutes..." +until = 'Passing selfcheck|OTA update successful' +fail_on = 'OTA update failed|Failing selfcheck' +timeout_s = 600 +on_ok = "end:update_ok" +on_fail = "end:update_failed" +on_timeout = "end:update_slow" + +[[guide.end]] +id = "update_ok" +severity = "ok" +title = "Update finished" +text = "The new firmware was installed or accepted." +actions = ["Wait for the green LED."] + +[[guide.end]] +id = "update_failed" +severity = "error" +title = "Update failed" +text = "The box keeps its current firmware and retries later." +actions = ["Check the Wi-Fi is stable. The Diagnosis tab shows why it failed."] + +[[guide.end]] +id = "update_slow" +severity = "warning" +title = "Update is taking very long" +text = "No end of the update was seen in 10 minutes." +actions = ["Check the Wi-Fi signal; leave the box powered and check again later."] + +# =================================================================== power +[[guide]] +id = "power" +title = "LED off" +summary = "Power switch, heartbeat LED and USB connection." +recommend = ["led:OFF"] +start = "switch" + +[[guide.step]] +id = "switch" +type = "question" +text = "Is the power switch on the outside of the case set to ON?" +options = [ + { label = "It was OFF - I switched it on", next = "boot" }, + { label = "It is ON", next = "heartbeat" }, +] + +[[guide.step]] +id = "boot" +type = "wait" +text = "Waiting for the box to start (up to 30 s)..." +until_milestone = "app_started" +timeout_s = 30 +on_ok = "end:was_off" +on_timeout = "heartbeat" + +[[guide.step]] +id = "heartbeat" +type = "question" +text = "Is the small LED next to the RGB LED blinking?" +options = [ + { label = "Yes, blinking", next = "end:rgb_led_fault" }, + { label = "No", next = "end:no_power" }, +] + +[[guide.end]] +id = "was_off" +severity = "ok" +title = "Box was switched off" +text = "It starts normally after switching on." +actions = ["Run the Quick check again once the LED shows a colour."] + +[[guide.end]] +id = "rgb_led_fault" +severity = "warning" +title = "Firmware runs, but the RGB LED stays dark" +text = "The heartbeat LED shows the firmware is running; the RGB LED or its wiring is faulty." +actions = ["Use the Diagnosis tab instead of the LED; replace the LED board when convenient."] + +[[guide.end]] +id = "no_power" +severity = "error" +title = "Box does not start" +text = "Neither LED is lit: no power or the firmware does not start." +actions = [ + "Check the power supply and cable.", + "If the USB port shows up in this tool, re-flash the official firmware (Flash firmware tab, App only).", +] + +# =================================================================== clock +[[guide]] +id = "clock" +title = "Clock not set" +summary = "Why readings are on hold and when the coin cell matters." +recommend = ["rtc.clock_not_running", "rtc.lost_after_sync", "kota.rtc_stopped", "kota.rtc_not_ticking", "kota.time_invalid"] +start = "internet" + +[[guide.step]] +id = "internet" +type = "question" +text = "The box clock is not set, so readings are on hold. Will the box have Internet at its installation site?" +options = [ + { label = "Yes", next = "end:clock_auto" }, + { label = "No / not yet", next = "end:clock_offline" }, +] + +[[guide.end]] +id = "clock_auto" +severity = "info" +title = "Clock will be set automatically" +text = "On its first Internet connection the box sets its clock and starts recording." +actions = ["If the clock is lost again after every power-off, replace the RTC coin cell (inside the case)."] + +[[guide.end]] +id = "clock_offline" +severity = "error" +title = "Box will not record data without Internet" +text = "Without the Internet the clock cannot be set, and the firmware holds all readings until it is." +actions = [ + "Give the box Internet access (pair it in Matter).", + "Replace the RTC coin cell (inside the case) so the time survives power-offs.", +] + +# ===================================================================== mic +[[guide]] +id = "mic" +title = "Microphone check" +summary = "Switched-off or silent microphone." +recommend = ["mic.disabled", "mic.silent", "mic.read_fail"] +start = "which" + +[[guide.step]] +id = "which" +type = "route" +routes = { "mic.disabled" = "switch", "mic.silent" = "clap", "mic.read_fail" = "end:mic_faulty" } +default = "clap" + +[[guide.step]] +id = "switch" +type = "question" +text = "The box reads its microphone switch as OFF. The switch is on the back of the case, just above the power switch (no need to open the box). Should this box measure noise?" +options = [ + { label = "Yes - I flipped the microphone switch", next = "switch_verify" }, + { label = "No - it is meant to be off", next = "end:mic_off_ok" }, +] + +[[guide.step]] +id = "switch_verify" +type = "wait" +# The firmware re-reads the switch before every reading (~10 s): no restart needed. +text = "Waiting for the next microphone reading (up to 30 s)..." +until = 'MICROPHONE: Read \d+ bytes' +fail_on = 'Microphone is in shutdown state' +timeout_s = 30 +on_ok = "end:mic_on" +on_fail = "flip_back" +on_timeout = "flip_back" + +[[guide.step]] +id = "flip_back" +type = "question" +text = "The box still reads the switch as OFF. Flip the microphone switch to the other position (it may have been ON already)." +options = [ + { label = "Done - I flipped it to the other position", next = "switch_verify2" }, + { label = "Leave it off", next = "end:mic_off_ok" }, +] + +[[guide.step]] +id = "switch_verify2" +type = "wait" +text = "Waiting for the next microphone reading (up to 30 s)..." +until = 'MICROPHONE: Read \d+ bytes' +fail_on = 'Microphone is in shutdown state' +timeout_s = 30 +on_ok = "end:mic_on" +on_fail = "end:mic_switch_faulty" +on_timeout = "end:mic_switch_faulty" + +[[guide.step]] +id = "clap" +type = "wait" +text = "Check the microphone switch (back of the case, above the power switch) is ON. Then clap your hands or speak loudly right next to the box for about 20 seconds..." +until = 'dB peak: (?!-inf)' +timeout_s = 45 +on_ok = "end:mic_ok" +on_timeout = "end:mic_faulty" + +[[guide.end]] +id = "mic_on" +severity = "ok" +title = "Microphone is on and recording" +text = "The firmware reads audio samples again." +actions = ["Leave the microphone switch in this position."] + +[[guide.end]] +id = "mic_off_ok" +severity = "info" +title = "Microphone intentionally off" +text = "No noise data is recorded for this box; everything else works." +actions = [] + +[[guide.end]] +id = "mic_switch_faulty" +severity = "warning" +title = "Microphone switch has no effect" +text = "The box reads the microphone as OFF in both switch positions (enable line GPIO3)." +actions = ["Check the switch and its cable to the main board (inside the case); replace the switch if needed."] + +[[guide.end]] +id = "mic_ok" +severity = "ok" +title = "Microphone works" +text = "It picked up sound - the earlier silence was just a very quiet room." +actions = [] + +[[guide.end]] +id = "mic_faulty" +severity = "error" +title = "Microphone records nothing" +text = "Even loud noise next to the box gives no signal, although the switch is on." +actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."] diff --git a/kota_tech/diag/kota.py b/kota_tech/diag/kota.py new file mode 100644 index 0000000..95c730a --- /dev/null +++ b/kota_tech/diag/kota.py @@ -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 diff --git a/kota_tech/diag/rules.toml b/kota_tech/diag/rules.toml index 489a624..1792c35 100644 --- a/kota_tech/diag/rules.toml +++ b/kota_tech/diag/rules.toml @@ -1,572 +1,724 @@ -# Log signatures for the KOTA sensorbox firmware. -# -# Derived from the firmware source (log strings, Status::set_status calls) and -# checked against real captures (tests/fixtures). Patterns match the cleaned -# line text, not the tag: the deployed v1.0 build logs some messages under -# different tags than the source (e.g. SD mount under INFLUX_BUFFER). -# -# Fields: -# id unique, "." -# pattern Python regex (search). Named groups can be used in `title`. -# severity critical | error | warning | info | ignore -# ignore = never shown (benign ESP_LOGE lines, LED-only rules) -# title short technician-facing headline -# meaning what it means, in plain words -# actions what to do, in order (prefer no-unscrewing actions first) -# explained_by list of rule ids; while one of them is active this finding -# is shown as a consequence of it instead of a root cause -# milestone boot milestone reached when the line is seen -# clears finding ids resolved when this line is seen -# led status LED colour this line sets (firmware Status::set_status) -# led_from only change the LED if it currently has this colour -# (Status::set_status_conditional) -# split_by named group; one finding per distinct value (e.g. per sensor) -# min_count finding becomes active only after this many occurrences -# min_uptime_ms ignore occurrences earlier than this uptime - -# ---------------------------------------------------------------- milestones - -[[rule]] -id = "boot.app_started" -pattern = 'app_init: Project name:\s+kota-sensorboard' -severity = "ignore" -milestone = "app_started" -led = "GREEN" # Status::init() -> STATUS_OK - -[[rule]] -id = "boot.sd_mounted" -pattern = 'SD card mounted successfully' -severity = "ignore" -milestone = "sd_mounted" - -[[rule]] -id = "boot.env_loaded" -pattern = 'Read INFLUX_URL from \.env file' -severity = "ignore" -milestone = "env_loaded" - -[[rule]] -id = "boot.matter_started" -pattern = 'ESP-Matter started successfully' -severity = "ignore" -milestone = "matter_started" - -[[rule]] -id = "boot.local_wifi_mode" -pattern = 'SSID/PASSWORD found in \.env' -severity = "ignore" -milestone = "local_wifi_mode" - -[[rule]] -id = "boot.sensors_ready" -# DeviceStatus::init installs the temperature sensor; it is the last init -# step, and any earlier init failure aborts before reaching it. -pattern = 'temperature_sensor: Range \[' -severity = "ignore" -milestone = "sensors_ready" - -[[rule]] -id = "net.wifi_connected" -pattern = 'sta ip: \d|got ip:|WIFI_SERVICE: Got IP address|IPv4 address changed|Interface IP Address changed' -severity = "ignore" -milestone = "wifi_connected" -clears = ["matter.not_commissioned", "wifi.disconnected"] - -[[rule]] -id = "matter.commissioned" -pattern = 'MAIN: Commissioning complete|MAIN: Fabric is committed' -severity = "ignore" -milestone = "matter_commissioned" -clears = ["matter.not_commissioned", "matter.commissioning_failed"] - -[[rule]] -id = "net.time_synced" -pattern = 'SNTP: Time synchronized' -severity = "ignore" -milestone = "time_synced" -clears = ["rtc.clock_not_running", "net.dns_fail"] - -[[rule]] -id = "influx.upload_ok" -pattern = 'INFLUX: HTTP request performed, status code: 2\d\d' -severity = "ignore" -milestone = "data_uploaded" -clears = ["influx.buffer_fail", "net.dns_fail", "net.tls_fail"] - -[[rule]] -id = "influx.write_ok" -pattern = 'Successfully wrote to Influx' -severity = "ignore" -milestone = "data_uploaded" -led = "GREEN" -led_from = "PURPLE" - -[[rule]] -id = "ota.registered" -pattern = 'Device registered successfully|OTA token loaded from NVS' -severity = "ignore" -milestone = "ota_registered" -clears = ["ota.registration_failed"] - -[[rule]] -id = "ota.checked" -pattern = 'OTA_CLIENT: No update available' -severity = "ignore" -milestone = "ota_checked" -clears = ["ota.check_failed"] -led = "GREEN" -led_from = "PURPLE" - -# ----------------------------------------------------------- benign E lines - -[[rule]] -id = "benign.mac" -pattern = 'ID: Device MAC address' -severity = "ignore" - -[[rule]] -id = "benign.attr_cb" -pattern = 'MAIN: Attribute update callback' -severity = "ignore" - -[[rule]] -id = "benign.zcl" -pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL' -severity = "ignore" - -[[rule]] -id = "benign.connect_chain" -# Transport-level echo of a failure that a more specific rule reports. -pattern = 'esp-tls: Failed to open new connection|transport_base: Failed to open a new connection|HTTP_CLIENT: Connection failed, sock < 0|INFLUX: Failed to perform HTTP request: 0,|INFLUX_BUFFER: Failed to write chunk to Influx|OTA_CLIENT: Error ESP_ERR_HTTP_CONNECT at' -severity = "ignore" - -# ------------------------------------------------------------------ network - -[[rule]] -id = "matter.not_commissioned" -pattern = 'CHIPoBLE advertising started|MAIN: Commissioning window opened' -severity = "warning" -title = "Box is not connected to Wi-Fi (not commissioned in Matter)" -meaning = "The box is waiting to be added to a Matter network over Bluetooth. Until then it has no Wi-Fi, so it cannot upload data or receive firmware updates." -actions = [ - "Add the box in the customer's Matter app (Apple Home, Google Home, ...) by scanning the QR sticker, or enter the manual code printed under it.", - "Use a 2.4 GHz Wi-Fi network; keep the phone close to the box during pairing.", - "If pairing keeps failing: hold the Matter button 5 s (factory reset) and try again.", -] -milestone = "ble_advertising" - -[[rule]] -id = "matter.commissioning_failed" -pattern = 'Commissioning failed, fail safe timer expired' -severity = "error" -title = "Matter pairing attempt failed" -meaning = "A phone started adding the box to Matter but did not finish in time." -actions = [ - "Retry pairing from the Matter app with the phone next to the box.", - "Check the Wi-Fi is 2.4 GHz and the password entered in the app is correct.", -] - -[[rule]] -id = "net.dns_fail" -pattern = '''couldn't get hostname for :(?P[\w.-]+)''' -severity = "error" -title = "No Internet: server names cannot be resolved" -meaning = "The box could not look up a server name. Either it has no Wi-Fi connection, or the network has no working Internet/DNS." -actions = [ - "Check the site Wi-Fi has Internet (open a website on a phone connected to the same network).", - "If the box was never paired: commission it in Matter first.", - "Check the router does not block new devices (guest isolation, MAC filtering).", -] -explained_by = ["matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] - -[[rule]] -id = "net.tls_fail" -pattern = 'mbedtls_ssl_handshake returned|Failed to verify peer certificate|esp-tls-mbedtls: .*[Hh]andshake' -severity = "error" -title = "Secure connection to the server failed" -meaning = "The box reached the server but the encrypted (HTTPS) connection failed. Common causes: wrong clock on the box, or a network that intercepts HTTPS (corporate proxy/firewall)." -actions = [ - "Try another network (e.g. phone hotspot) to rule out a firewall.", - "Check the clock finding below: HTTPS needs the correct date.", -] -explained_by = ["rtc.clock_not_running"] - -[[rule]] -id = "wifi.disconnected" -pattern = 'WiFi disconnected, retrying in' -severity = "error" -min_count = 3 -title = "Box cannot connect to the configured Wi-Fi" -meaning = "The Wi-Fi name/password from the SD card does not work: wrong password, network out of range, or not 2.4 GHz." -actions = [ - "Check SSID and PASSWORD in ENV.TXT (re-provision the SD card with the tool).", - "Make sure the network is 2.4 GHz and in range.", -] - -[[rule]] -id = "wifi.setup_portal" -pattern = 'WiFi provisioning portal ready: connect to "(?P[^"]+)"' -severity = "warning" -title = "Box is in Wi-Fi setup mode (network {ssid})" -meaning = "The box is hosting its own Wi-Fi network for configuration instead of connecting to the site Wi-Fi." -actions = [ - "Connect a phone to {ssid} and open http://sensorbox.local/ to enter the Wi-Fi details,", - "or short-press the Matter button (< 2 s) to leave setup mode.", -] -led = "WHITE" - -# ------------------------------------------------------------------- InfluxDB - -[[rule]] -id = "influx.buffer_fail" -pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer' -severity = "error" -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." -actions = [ - "Fix the network problem first (see the finding this is grouped under).", - "Buffered data is uploaded automatically once the connection works.", -] -explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] - -[[rule]] -id = "influx.purple" -pattern = 'Failed to write chunk with \d+ retries to Influx|Failed to flush buffer before writing|Failed to append new entry to buffer' -severity = "ignore" -led = "PURPLE" - -[[rule]] -id = "influx.auth" -pattern = 'INFLUX: Failed to perform HTTP request: (?P401|403)' -severity = "critical" -title = "InfluxDB rejected the token (HTTP {code})" -meaning = "The box reaches the database but the INFLUX_TOKEN on the SD card is wrong or revoked." -actions = [ - "Re-provision the SD card with the current ENV.TXT (SD card is inside the case).", - "If all boxes show this: the shared token was rotated - update ENV.TXT for everyone.", -] - -[[rule]] -id = "influx.not_found" -pattern = 'INFLUX: Failed to perform HTTP request: (?P404)' -severity = "critical" -title = "InfluxDB bucket or organisation not found (HTTP 404)" -meaning = "INFLUX_URL on the SD card points to a bucket/org that does not exist." -actions = ["Re-provision the SD card with the correct ENV.TXT."] - -[[rule]] -id = "influx.server_error" -pattern = 'INFLUX: Failed to perform HTTP request: (?P5\d\d)' -severity = "warning" -title = "InfluxDB server error (HTTP {code})" -meaning = "The database server has a problem. The box is fine; data is buffered on the SD card." -actions = ["Nothing to fix on the box. Report to the backend team if it persists."] - -[[rule]] -id = "influx.bad_request" -pattern = 'INFLUX: Bad request, check line protocol format' -severity = "warning" -title = "InfluxDB refused some measurements (HTTP 400)" -meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)." -actions = ["Save the session log and send it to the firmware team."] - -[[rule]] -id = "influx.env_missing" -split_by = "key" -pattern = 'Failed to read (?PINFLUX_URL|INFLUX_TOKEN) from \.env file' -severity = "critical" -title = "ENV.TXT on the SD card is missing {key}" -meaning = "The box cannot start without the InfluxDB settings; it shows white and restarts." -actions = ["Re-provision the SD card with the tool (SD card is inside the case)."] - -[[rule]] -id = "influx.init_fail" -pattern = 'MAIN: Failed to initialize Influx buffer' -severity = "critical" -title = "Data upload could not be initialised" -meaning = "Start-up stopped because the InfluxDB settings could not be read from the SD card. The box restarts." -actions = ["Re-provision the SD card with the tool (SD card is inside the case)."] -explained_by = ["influx.env_missing", "sd.mount_fail", "sd.card_fail", "sd.env_open_fail"] -led = "WHITE" - -# ----------------------------------------------------------------- SD card - -[[rule]] -id = "sd.card_fail" -pattern = 'Failed to initialize the card \((?P[^)]+)\)' -severity = "critical" -title = "SD card not detected ({err})" -meaning = "The SD card does not respond: missing, not seated, or damaged. The box shows white and restarts." -actions = [ - "Open the case and re-seat the SD card.", - "Try a freshly provisioned card.", -] -led = "WHITE" - -[[rule]] -id = "sd.mount_fail" -pattern = 'Failed to mount filesystem' -severity = "critical" -title = "SD card is not formatted correctly" -meaning = "The card responds but has no readable FAT32 file system." -actions = ["Re-provision the card with the tool (FAT32, MBR) - SD card is inside the case."] -led = "WHITE" - -[[rule]] -id = "sd.env_open_fail" -pattern = 'Failed to open \.env file for reading' -severity = "critical" -title = "ENV.TXT not found on the SD card" -meaning = "The card is readable but the configuration file is missing." -actions = ["Re-provision the card with the tool (SD card is inside the case)."] - -[[rule]] -id = "sd.write_fail" -pattern = 'Failed to (create buffer file|open buffer file for appending)' -severity = "error" -title = "Cannot write to the SD card" -meaning = "Offline data cannot be stored: the card is full, write-protected or failing." -actions = ["Replace / re-provision the SD card (inside the case)."] - -# --------------------------------------------------------------- RTC / I2C - -[[rule]] -id = "rtc.init_failed" -pattern = 'MAIN: Failed to initialize RTC: (?P\S+)' -severity = "critical" -title = "Clock chip (RTC) or I2C bus not responding ({err})" -meaning = "The RTC is the first chip the firmware talks to over I2C, so this also appears when the I2C cable between the boards is loose or broken. LED blue, the box restarts." -actions = [ - "Open the case and check the I2C cable/connector between the boards.", - "If the cable is fine: the RTC module is faulty.", -] -led = "BLUE" - -[[rule]] -id = "rtc.clock_not_running" -pattern = 'RTC is uninitialized, using current time as timestamp' -severity = "warning" -min_count = 5 -title = "Clock is not set - sensor readings are on hold" -meaning = "The clock chip answers but its clock is not running (it reads 1 Jan 2000; typical after its coin cell was removed or is empty), and there is no Internet time yet. Readings are held until the time is set, so no data is recorded." -actions = [ - "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).", -] - -[[rule]] -id = "i2c.errors" -pattern = 'I2C (read|write) failed: (?P\S+)|Failed to add I2C device' -severity = "error" -min_count = 2 -title = "I2C bus communication errors" -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."] - -# ------------------------------------------------------------------ sensors - -[[rule]] -id = "sensor.init_failed" -split_by = "sensor" -pattern = 'MAIN: Failed to initialize (?PBME688|SEN66|AS7341|Microphone|Flicker sensor|DeviceStatus): (?P.+)' -severity = "critical" -title = "{sensor} failed to start" -meaning = "Start-up stopped at the {sensor} sensor ({err}). LED red, the box restarts." -actions = [ - "Open the case and check the {sensor} connector/cable.", - "If several sensors fail in turn: check the shared I2C cable.", -] -led = "RED" - -[[rule]] -id = "sensor.selfcheck_failed" -split_by = "sensor" -pattern = '(?PBME688|SEN66|AS7341|Microphone|Flicker sensor) self check failed' -severity = "error" -title = "{sensor} failed the post-update self-check" -meaning = "After a firmware update the {sensor} did not work; the box rolls back to the previous firmware." -actions = ["Check the {sensor} connector (inside the case), then power-cycle."] - -[[rule]] -id = "sen66.not_ready" -pattern = 'SEN66: (Failed while awaiting sensor ready|Self-check failed: Sensor not ready|Failed to read measurement)' -severity = "error" -title = "Air-quality sensor (SEN66) not delivering data" -meaning = "The SEN66 answers but never reports a measurement." -actions = ["Check the SEN66 cable (inside the case); the sensor may be faulty."] - -[[rule]] -id = "bme688.read_fail" -pattern = 'Failed to get (BME688 )?data from BME688|No new data available from BME688' -severity = "warning" -min_count = 3 -title = "Temperature/humidity sensor (BME688) read errors" -meaning = "The BME688 repeatedly fails to return a reading." -actions = ["Check the BME688 connection (inside the case)."] - -[[rule]] -id = "as7341.read_fail" -pattern = 'Failed to read (spectral|basic counts) data' -severity = "warning" -min_count = 2 -title = "Light sensor (AS7341) read errors" -meaning = "The AS7341 repeatedly fails to return a reading." -actions = ["Check the AS7341 connection (inside the case)."] - -[[rule]] -id = "mic.silent" -pattern = 'MICROPHONE: .*dB peak: -inf' -severity = "warning" -min_count = 2 -title = "Microphone records pure silence" -meaning = "Every audio sample is zero. A real room is never perfectly silent, so the microphone or its wiring is likely faulty." -actions = [ - "Make some noise near the box and watch whether this finding keeps growing.", - "If it does: check the microphone board/cable (inside the case).", -] - -[[rule]] -id = "mic.read_fail" -pattern = 'Failed to read samples' -severity = "error" -title = "Microphone read error" -meaning = "The audio interface returned an error." -actions = ["Check the microphone board/cable (inside the case)."] - -[[rule]] -id = "gather.stalled" -pattern = 'tasks still running, waiting' -severity = "warning" -min_uptime_ms = 180000 -min_count = 5 -title = "Sensor readings are stuck" -meaning = "A sensor has not finished its reading for minutes (the first 2 minutes of warm-up are normal)." -actions = ["See the grouped cause, or note which sensor stopped logging and check its cable."] -explained_by = ["rtc.clock_not_running", "sen66.not_ready"] - -# ---------------------------------------------------------- OTA / firmware - -[[rule]] -id = "ota.registration_failed" -pattern = 'Device registration failed' -severity = "warning" -title = "Box is not registered for firmware updates" -meaning = "The box could not reach the update server (bin.kota-app.com) to register, so it will not receive new firmware." -actions = ["Fix the network problem; registration is retried automatically every few seconds."] -explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] - -[[rule]] -id = "ota.check_failed" -pattern = 'Failed to check for updates|OTA Update check failed with status code: (?P\d+)' -severity = "error" -title = "Firmware update check failed" -meaning = "The update server could not be reached or refused the request. LED purple." -actions = ["Fix the network problem; the check is retried every 15 minutes."] -explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected"] -led = "PURPLE" - -[[rule]] -id = "ota.unauthorized" -pattern = 'Unauthorized, clearing saved token' -severity = "warning" -title = "Update server did not accept the box - it will re-register" -meaning = "The saved update token was rejected; the box registers again automatically." -actions = ["Nothing to do unless it repeats. Then report the box MAC to the backend team."] - -[[rule]] -id = "ota.updating" -pattern = 'New update available, starting OTA update' -severity = "info" -title = "Firmware update is downloading - do not switch off" -meaning = "The box found new firmware and is installing it (LED yellow). It restarts when done." -actions = ["Wait. Do not switch the power off."] -milestone = "ota_update_started" -led = "YELLOW" - -[[rule]] -id = "ota.update_failed" -pattern = 'OTA update failed: (?P\S+)' -severity = "error" -title = "Firmware update failed ({err})" -meaning = "The download or installation was interrupted; the box keeps its current firmware and retries later." -actions = ["Check the network is stable; the update is retried automatically."] - -[[rule]] -id = "ota.update_ok" -pattern = 'OTA update successful, rebooting' -severity = "info" -title = "Firmware update installed - restarting" -meaning = "New firmware was installed. After the restart it runs a self-check (LED orange, ~2.5 min)." -actions = ["Wait for green."] -milestone = "ota_update_done" - -[[rule]] -id = "ota.selfcheck" -pattern = 'Device is in selfcheck mode' -severity = "info" -title = "Post-update self-check running (~2.5 min)" -meaning = "The new firmware is testing all sensors before it is accepted (LED orange)." -actions = ["Wait. Do not switch off."] -led = "ORANGE" - -[[rule]] -id = "ota.selfcheck_failed" -pattern = 'Failing selfcheck: (?P.+)' -severity = "error" -title = "Update self-check failed: {reason}" -meaning = "The new firmware rejected itself; the box rolls back to the previous version." -actions = ["Check the named sensor (inside the case), then power-cycle."] -led = "RED" - -[[rule]] -id = "ota.selfcheck_passed" -pattern = 'Passing selfcheck' -severity = "ignore" -milestone = "ota_update_accepted" -led = "GREEN" - -[[rule]] -id = "ota.reset_during_update" -pattern = 'Device was reset during update' -severity = "warning" -title = "Box was switched off during a firmware update" -meaning = "An update was interrupted; the box reports it and retries." -actions = ["Leave the box powered while the LED is yellow or orange."] - -# --------------------------------------------------------------- system - -[[rule]] -id = "sys.out_of_resources" -pattern = 'Failed to create (task for sensor|gather task|main gather task)' -severity = "error" -title = "Firmware ran out of memory" -meaning = "The firmware could not start an internal task (LED red); it restarts itself after 30 s." -actions = ["Power-cycle. If it repeats, save the session log for the firmware team."] -led = "RED" - -[[rule]] -id = "sys.factory_reset" -pattern = 'Factory reset button pressed|Factory resetting device' -severity = "warning" -title = "Factory reset triggered with the Matter button" -meaning = "The box forgets its Matter network and must be paired again with the QR sticker." -actions = ["Re-commission the box in the Matter app."] -led = "WHITE" - -[[rule]] -id = "mic.disabled" -pattern = 'MICROPHONE: Microphone is in shutdown state' -severity = "info" -title = "Microphone is switched off - no noise measurements" -meaning = "The microphone switch on the back of the case (just above the power switch) is OFF, so noise levels are not recorded." -actions = ["If this box should measure noise: flip the microphone switch (back of the case, above the power switch). No restart needed - it is picked up within about 10 s."] - -[[rule]] -id = "gather.empty_reading" -pattern = 'SENSOR_GATHERER: Failed to get line protocol from sensor' -severity = "warning" -min_count = 2 -title = "A sensor returned no data" -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."] -explained_by = ["mic.disabled", "mic.read_fail", "bme688.read_fail", "as7341.read_fail", "rtc.clock_not_running"] +# Log signatures for the KOTA sensorbox firmware. +# +# Derived from the firmware source (log strings, Status::set_status calls) and +# checked against real captures (tests/fixtures). Patterns match the cleaned +# line text, not the tag: the deployed v1.0 build logs some messages under +# different tags than the source (e.g. SD mount under INFLUX_BUFFER). +# +# Fields: +# id unique, "." +# 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 +# ignore = never shown (benign ESP_LOGE lines, LED-only rules) +# title short technician-facing headline +# meaning what it means, in plain words +# actions what to do, in order (prefer no-unscrewing actions first) +# explained_by list of rule ids; while one of them is active this finding +# is shown as a consequence of it instead of a root cause +# milestone boot milestone reached when the line is seen +# clears finding ids resolved when this line is seen +# led status LED colour this line sets (firmware Status::set_status) +# led_from only change the LED if it currently has this colour +# (Status::set_status_conditional) +# split_by named group; one finding per distinct value (e.g. per sensor) +# min_count finding becomes active only after this many occurrences +# min_uptime_ms ignore occurrences earlier than this uptime + +# ---------------------------------------------------------------- milestones + +[[rule]] +id = "boot.app_started" +pattern = 'app_init: Project name:\s+kota-sensorboard' +severity = "ignore" +milestone = "app_started" +led = "GREEN" # Status::init() -> STATUS_OK + +[[rule]] +id = "boot.sd_mounted" +pattern = 'SD card mounted successfully' +severity = "ignore" +milestone = "sd_mounted" + +[[rule]] +id = "boot.env_loaded" +pattern = 'Read INFLUX_URL from \.env file' +severity = "ignore" +milestone = "env_loaded" + +[[rule]] +id = "boot.matter_started" +pattern = 'ESP-Matter started successfully' +severity = "ignore" +milestone = "matter_started" + +[[rule]] +id = "boot.local_wifi_mode" +pattern = 'SSID/PASSWORD found in \.env' +severity = "ignore" +milestone = "local_wifi_mode" + +[[rule]] +id = "boot.sensors_ready" +# DeviceStatus::init installs the temperature sensor; it is the last init +# step, and any earlier init failure aborts before reaching it. +pattern = 'temperature_sensor: Range \[' +severity = "ignore" +milestone = "sensors_ready" + +[[rule]] +id = "net.wifi_connected" +pattern = 'sta ip: \d|got ip:|WIFI_SERVICE: Got IP address|IPv4 address changed|Interface IP Address changed' +severity = "ignore" +milestone = "wifi_connected" +clears = ["matter.not_commissioned", "wifi.disconnected"] + +[[rule]] +id = "matter.commissioned" +pattern = 'MAIN: Commissioning complete|MAIN: Fabric is committed' +severity = "ignore" +milestone = "matter_commissioned" +clears = ["matter.not_commissioned", "matter.commissioning_failed"] + +[[rule]] +id = "net.time_synced" +pattern = 'SNTP: Time synchronized' +severity = "ignore" +milestone = "time_synced" +clears = ["rtc.clock_not_running", "net.dns_fail"] + +[[rule]] +id = "influx.upload_ok" +pattern = 'INFLUX: HTTP request performed, status code: 2\d\d' +severity = "ignore" +milestone = "data_uploaded" +clears = ["influx.buffer_fail", "net.dns_fail", "net.tls_fail"] + +[[rule]] +id = "influx.write_ok" +pattern = 'Successfully wrote to Influx' +severity = "ignore" +milestone = "data_uploaded" +led = "GREEN" +led_from = "PURPLE" + +[[rule]] +id = "ota.registered" +pattern = 'Device registered successfully|OTA token loaded from NVS' +severity = "ignore" +milestone = "ota_registered" +clears = ["ota.registration_failed"] + +[[rule]] +id = "ota.checked" +pattern = 'OTA_CLIENT: No update available' +severity = "ignore" +milestone = "ota_checked" +clears = ["ota.check_failed"] +led = "GREEN" +led_from = "PURPLE" + +# ----------------------------------------------------------- benign E lines + +[[rule]] +id = "benign.mac" +pattern = 'Device MAC address' +severity = "ignore" + +[[rule]] +id = "benign.attr_cb" +pattern = 'MAIN: Attribute update callback' +severity = "ignore" + +[[rule]] +id = "benign.zcl" +pattern = 'chip\[ZCL\]: WRITE ERR|data_model: Cluster cannot be NULL' +severity = "ignore" + +[[rule]] +id = "benign.connect_chain" +# Transport-level echo of a failure that a more specific rule reports. +pattern = 'esp-tls: Failed to open new connection|transport_base: Failed to open a new connection|HTTP_CLIENT: Connection failed, sock < 0|INFLUX: Failed to perform HTTP request: 0,|INFLUX_BUFFER: Failed to write chunk to Influx|OTA_CLIENT: Error ESP_ERR_HTTP_CONNECT at' +severity = "ignore" + +# ------------------------------------------------------------------ network + +[[rule]] +id = "matter.not_commissioned" +pattern = 'CHIPoBLE advertising started|MAIN: Commissioning window opened' +severity = "warning" +title = "Box is not connected to Wi-Fi (not commissioned in Matter)" +meaning = "The box is waiting to be added to a Matter network over Bluetooth. Until then it has no Wi-Fi, so it cannot upload data or receive firmware updates." +actions = [ + "Add the box in the customer's Matter app (Apple Home, Google Home, ...) by scanning the QR sticker, or enter the manual code printed under it.", + "Use a 2.4 GHz Wi-Fi network; keep the phone close to the box during pairing.", + "If pairing keeps failing: hold the Matter button 5 s (factory reset) and try again.", +] +milestone = "ble_advertising" + +[[rule]] +id = "matter.commissioning_failed" +pattern = 'Commissioning failed, fail safe timer expired' +severity = "error" +title = "Matter pairing attempt failed" +meaning = "A phone started adding the box to Matter but did not finish in time." +actions = [ + "Retry pairing from the Matter app with the phone next to the box.", + "Check the Wi-Fi is 2.4 GHz and the password entered in the app is correct.", +] + +[[rule]] +id = "net.dns_fail" +pattern = '''couldn't get hostname for :(?P[\w.-]+)''' +severity = "error" +title = "No Internet: server names cannot be resolved" +meaning = "The box could not look up a server name. Either it has no Wi-Fi connection, or the network has no working Internet/DNS." +actions = [ + "Check the site Wi-Fi has Internet (open a website on a phone connected to the same network).", + "If the box was never paired: commission it in Matter first.", + "Check the router does not block new devices (guest isolation, MAC filtering).", +] +explained_by = ["matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] + +[[rule]] +id = "net.tls_fail" +pattern = 'mbedtls_ssl_handshake returned|Failed to verify peer certificate|esp-tls-mbedtls: .*[Hh]andshake' +severity = "error" +title = "Secure connection to the server failed" +meaning = "The box reached the server but the encrypted (HTTPS) connection failed. Common causes: wrong clock on the box, or a network that intercepts HTTPS (corporate proxy/firewall)." +actions = [ + "Try another network (e.g. phone hotspot) to rule out a firewall.", + "Check the clock finding below: HTTPS needs the correct date.", +] +explained_by = ["rtc.clock_not_running"] + +[[rule]] +id = "wifi.disconnected" +pattern = 'WiFi disconnected, retrying in' +severity = "error" +min_count = 3 +title = "Box cannot connect to the configured Wi-Fi" +meaning = "The Wi-Fi name/password from the SD card does not work: wrong password, network out of range, or not 2.4 GHz." +actions = [ + "Check SSID and PASSWORD in ENV.TXT (re-provision the SD card with the tool).", + "Make sure the network is 2.4 GHz and in range.", +] + +[[rule]] +id = "wifi.setup_portal" +pattern = 'WiFi provisioning portal ready: connect to "(?P[^"]+)"' +severity = "warning" +title = "Box is in Wi-Fi setup mode (network {ssid})" +meaning = "The box is hosting its own Wi-Fi network for configuration instead of connecting to the site Wi-Fi." +actions = [ + "Connect a phone to {ssid} and open http://sensorbox.local/ to enter the Wi-Fi details,", + "or short-press the Matter button (< 2 s) to leave setup mode.", +] +led = "WHITE" + +# ------------------------------------------------------------------- InfluxDB + +[[rule]] +id = "influx.buffer_fail" +pattern = 'Failed to write chunk with \d+ retries to Influx|MAIN: Failed to flush Influx buffer' +severity = "error" +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 (firmware v1.0 also restarts itself after about 2.5 minutes in this state)." +actions = [ + "Fix the network problem first (see the finding this is grouped under).", + "Buffered data is uploaded automatically once the connection works.", +] +explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] + +[[rule]] +id = "influx.purple" +pattern = 'Failed to write chunk with \d+ retries to Influx|Failed to flush buffer before writing|Failed to append new entry to buffer' +severity = "ignore" +led = "PURPLE" + +[[rule]] +id = "influx.auth" +pattern = 'INFLUX: Failed to perform HTTP request: (?P401|403)' +severity = "critical" +title = "InfluxDB rejected the token (HTTP {code})" +meaning = "The box reaches the database but the INFLUX_TOKEN on the SD card is wrong or revoked." +actions = [ + "Re-provision the SD card with the current ENV.TXT (SD card is inside the case).", + "If all boxes show this: the shared token was rotated - update ENV.TXT for everyone.", +] + +[[rule]] +id = "influx.not_found" +pattern = 'INFLUX: Failed to perform HTTP request: (?P404)' +severity = "critical" +title = "InfluxDB bucket or organisation not found (HTTP 404)" +meaning = "INFLUX_URL on the SD card points to a bucket/org that does not exist." +actions = ["Re-provision the SD card with the correct ENV.TXT."] + +[[rule]] +id = "influx.server_error" +pattern = 'INFLUX: Failed to perform HTTP request: (?P5\d\d)' +severity = "warning" +title = "InfluxDB server error (HTTP {code})" +meaning = "The database server has a problem. The box is fine; data is buffered on the SD card." +actions = ["Nothing to fix on the box. Report to the backend team if it persists."] + +[[rule]] +id = "influx.bad_request" +pattern = 'INFLUX: Bad request, check line protocol format' +severity = "warning" +title = "InfluxDB refused some measurements (HTTP 400)" +meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)." +actions = ["Save the session log and send it to the firmware team."] + +[[rule]] +id = "influx.env_missing" +split_by = "key" +pattern = 'Failed to read (?PINFLUX_URL|INFLUX_TOKEN) from \.env file' +severity = "critical" +title = "ENV.TXT on the SD card is missing {key}" +meaning = "The box cannot start without the InfluxDB settings; it shows white and restarts." +actions = ["Re-provision the SD card with the tool (SD card is inside the case)."] + +[[rule]] +id = "influx.init_fail" +pattern = 'MAIN: Failed to initialize Influx buffer' +severity = "critical" +title = "Data upload could not be initialised" +meaning = "Start-up stopped because the InfluxDB settings could not be read from the SD card. The box restarts." +actions = ["Re-provision the SD card with the tool (SD card is inside the case)."] +explained_by = ["influx.env_missing", "sd.mount_fail", "sd.card_fail", "sd.env_open_fail"] +led = "WHITE" + +# ----------------------------------------------------------------- SD card + +[[rule]] +id = "sd.card_fail" +pattern = 'Failed to initialize the card \((?P[^)]+)\)' +severity = "critical" +title = "SD card not detected ({err})" +meaning = "The SD card does not respond: missing, not seated, or damaged. The box shows white and restarts." +actions = [ + "Open the case and re-seat the SD card.", + "Try a freshly provisioned card.", +] +led = "WHITE" + +[[rule]] +id = "sd.mount_fail" +pattern = 'Failed to mount filesystem' +severity = "critical" +title = "SD card is not formatted correctly" +meaning = "The card responds but has no readable FAT32 file system." +actions = ["Re-provision the card with the tool (FAT32, MBR) - SD card is inside the case."] +led = "WHITE" + +[[rule]] +id = "sd.env_open_fail" +pattern = 'Failed to open \.env file for reading' +severity = "critical" +title = "ENV.TXT not found on the SD card" +meaning = "The card is readable but the configuration file is missing." +actions = ["Re-provision the card with the tool (SD card is inside the case)."] + +[[rule]] +id = "sd.write_fail" +pattern = 'Failed to (create buffer file|open buffer file for appending)' +severity = "error" +title = "Cannot write to the SD card" +meaning = "Offline data cannot be stored: the card is full, write-protected or failing." +actions = ["Replace / re-provision the SD card (inside the case)."] + +# --------------------------------------------------------------- RTC / I2C + +[[rule]] +id = "rtc.init_failed" +pattern = 'MAIN: Failed to initialize RTC: (?P\S+)' +severity = "critical" +title = "Clock chip (RTC) or I2C bus not responding ({err})" +meaning = "The RTC is the first chip the firmware talks to over I2C, so this also appears when the I2C cable between the boards is loose or broken. LED blue, the box restarts." +actions = [ + "Open the case and check the I2C cable/connector between the boards.", + "If the cable is fine: the RTC module is faulty.", +] +explained_by = ["kota.i2c_stuck"] +led = "BLUE" + +[[rule]] +id = "rtc.clock_not_running" +pattern = 'RTC is uninitialized, using current time as timestamp' +severity = "warning" +min_count = 5 +title = "Clock is not set - sensor readings are on hold" +meaning = "The clock chip answers but its clock is not running (it reads 1 Jan 2000; typical after its coin cell was removed or is empty), and there is no Internet time yet. Readings are held until the time is set, so no data is recorded." +actions = [ + "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).", +] +explained_by = ["kota.rtc_stopped"] + +[[rule]] +id = "i2c.errors" +pattern = 'I2C (read|write) failed: (?P\S+)|Failed to add I2C device' +severity = "error" +min_count = 2 +title = "I2C bus communication errors" +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."] +explained_by = ["kota.i2c_stuck", "kota.sensor_missing"] + +# ------------------------------------------------------------------ sensors + +[[rule]] +id = "sensor.init_failed" +split_by = "sensor" +pattern = 'MAIN: Failed to initialize (?PBME688|SEN66|AS7341|Microphone|Flicker sensor|DeviceStatus): (?P.+)' +severity = "critical" +title = "{sensor} failed to start" +meaning = "Start-up stopped at the {sensor} sensor ({err}). LED red, the box restarts." +actions = [ + "Open the case and check the {sensor} connector/cable.", + "If several sensors fail in turn: check the shared I2C cable.", +] +explained_by = ["kota.i2c_stuck", "kota.sensor_missing"] +led = "RED" + +[[rule]] +id = "sensor.selfcheck_failed" +split_by = "sensor" +pattern = '(?PBME688|SEN66|AS7341|Microphone|Flicker sensor) self check failed' +severity = "error" +title = "{sensor} failed the post-update self-check" +meaning = "After a firmware update the {sensor} did not work; the box rolls back to the previous firmware." +actions = ["Check the {sensor} connector (inside the case), then power-cycle."] + +[[rule]] +id = "sen66.not_ready" +pattern = 'SEN66: (Failed while awaiting sensor ready|Self-check failed: Sensor not ready|Failed to read measurement)' +severity = "error" +title = "Air-quality sensor (SEN66) not delivering data" +meaning = "The SEN66 answers but never reports a measurement." +actions = ["Check the SEN66 cable (inside the case); the sensor may be faulty."] + +[[rule]] +id = "bme688.read_fail" +pattern = 'Failed to get (BME688 )?data from BME688|No new data available from BME688' +severity = "warning" +min_count = 3 +title = "Temperature/humidity sensor (BME688) read errors" +meaning = "The BME688 repeatedly fails to return a reading." +actions = ["Check the BME688 connection (inside the case)."] + +[[rule]] +id = "as7341.read_fail" +pattern = 'Failed to read (spectral|basic counts) data' +severity = "warning" +min_count = 2 +title = "Light sensor (AS7341) read errors" +meaning = "The AS7341 repeatedly fails to return a reading." +actions = ["Check the AS7341 connection (inside the case)."] + +[[rule]] +id = "mic.silent" +pattern = 'MICROPHONE: .*dB peak: -inf' +severity = "warning" +min_count = 2 +title = "Microphone records pure silence" +meaning = "Every audio sample is zero. A real room is never perfectly silent, so the microphone or its wiring is likely faulty." +actions = [ + "Make some noise near the box and watch whether this finding keeps growing.", + "If it does: check the microphone board/cable (inside the case).", +] + +[[rule]] +id = "mic.read_fail" +pattern = 'Failed to read samples' +severity = "error" +title = "Microphone read error" +meaning = "The audio interface returned an error." +actions = ["Check the microphone board/cable (inside the case)."] + +[[rule]] +id = "gather.stalled" +pattern = 'tasks still running, waiting' +severity = "warning" +min_uptime_ms = 180000 +min_count = 5 +title = "Sensor readings are stuck" +meaning = "A sensor has not finished its reading for minutes (the first 2 minutes of warm-up are normal)." +actions = ["See the grouped cause, or note which sensor stopped logging and check its cable."] +explained_by = ["rtc.clock_not_running", "sen66.not_ready"] + +# ---------------------------------------------------------- OTA / firmware + +[[rule]] +id = "ota.registration_failed" +pattern = 'Device registration failed' +severity = "warning" +title = "Box is not registered for firmware updates" +meaning = "The box could not reach the update server (bin.kota-app.com) to register, so it will not receive new firmware." +actions = ["Fix the network problem; registration is retried automatically every few seconds."] +explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected", "wifi.setup_portal"] + +[[rule]] +id = "ota.check_failed" +pattern = 'Failed to check for updates|OTA Update check failed with status code: (?P\d+)' +severity = "error" +title = "Firmware update check failed" +meaning = "The update server could not be reached or refused the request. LED purple." +actions = ["Fix the network problem; the check is retried every 15 minutes."] +explained_by = ["net.dns_fail", "net.tls_fail", "matter.not_commissioned", "wifi.disconnected"] +led = "PURPLE" + +[[rule]] +id = "ota.unauthorized" +pattern = 'Unauthorized, clearing saved token' +severity = "warning" +title = "Update server did not accept the box - it will re-register" +meaning = "The saved update token was rejected; the box registers again automatically." +actions = ["Nothing to do unless it repeats. Then report the box MAC to the backend team."] + +[[rule]] +id = "ota.updating" +pattern = 'New update available, starting OTA update' +severity = "info" +title = "Firmware update is downloading - do not switch off" +meaning = "The box found new firmware and is installing it (LED yellow). It restarts when done." +actions = ["Wait. Do not switch the power off."] +milestone = "ota_update_started" +led = "YELLOW" + +[[rule]] +id = "ota.update_failed" +pattern = 'OTA update failed: (?P\S+)' +severity = "error" +title = "Firmware update failed ({err})" +meaning = "The download or installation was interrupted; the box keeps its current firmware and retries later." +actions = ["Check the network is stable; the update is retried automatically."] + +[[rule]] +id = "ota.update_ok" +pattern = 'OTA update successful, rebooting' +severity = "info" +title = "Firmware update installed - restarting" +meaning = "New firmware was installed. After the restart it runs a self-check (LED orange, ~2.5 min)." +actions = ["Wait for green."] +milestone = "ota_update_done" + +[[rule]] +id = "ota.selfcheck" +pattern = 'Device is in selfcheck mode' +severity = "info" +title = "Post-update self-check running (~2.5 min)" +meaning = "The new firmware is testing all sensors before it is accepted (LED orange)." +actions = ["Wait. Do not switch off."] +led = "ORANGE" + +[[rule]] +id = "ota.selfcheck_failed" +pattern = 'Failing selfcheck: (?P.+)' +severity = "error" +title = "Update self-check failed: {reason}" +meaning = "The new firmware rejected itself; the box rolls back to the previous version." +actions = ["Check the named sensor (inside the case), then power-cycle."] +led = "RED" + +[[rule]] +id = "ota.selfcheck_passed" +pattern = 'Passing selfcheck' +severity = "ignore" +milestone = "ota_update_accepted" +led = "GREEN" + +[[rule]] +id = "ota.reset_during_update" +pattern = 'Device was reset during update' +severity = "warning" +title = "Box was switched off during a firmware update" +meaning = "An update was interrupted; the box reports it and retries." +actions = ["Leave the box powered while the LED is yellow or orange."] + +# --------------------------------------------------------------- system + +[[rule]] +id = "sys.out_of_resources" +pattern = 'Failed to create (task for sensor|gather task|main gather task)' +severity = "error" +title = "Firmware ran out of memory" +meaning = "The firmware could not start an internal task (LED red); it restarts itself after 30 s." +actions = ["Power-cycle. If it repeats, save the session log for the firmware team."] +led = "RED" + +[[rule]] +id = "sys.factory_reset" +pattern = 'Factory reset button pressed|Factory resetting device' +severity = "warning" +title = "Factory reset triggered with the Matter button" +meaning = "The box forgets its Matter network and must be paired again with the QR sticker." +actions = ["Re-commission the box in the Matter app."] +led = "WHITE" + +[[rule]] +id = "mic.disabled" +pattern = 'MICROPHONE: Microphone is in shutdown state' +severity = "info" +title = "Microphone is switched off - no noise measurements" +meaning = "The microphone switch on the back of the case (just above the power switch) is OFF, so noise levels are not recorded." +actions = ["If this box should measure noise: flip the microphone switch (back of the case, above the power switch). No restart needed - it is picked up within about 10 s."] + +[[rule]] +id = "gather.empty_reading" +pattern = 'SENSOR_GATHERER: Failed to get line protocol from sensor' +severity = "warning" +min_count = 2 +title = "A sensor returned no data" +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."] +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: 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"] diff --git a/kota_tech/flash/image.py b/kota_tech/flash/image.py index 4e1d67b..aa3ab5e 100644 --- a/kota_tech/flash/image.py +++ b/kota_tech/flash/image.py @@ -1,303 +1,260 @@ -"""Firmware images: validation, integrity and layout. - -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: - * merged - bootloader at 0x0 + partition table + app (like new_system.bin); - App-only or Full flashing; - * app - the application binary only (build/kota-sensorboard.bin); - App-only flashing, laid out like the factory image. - -Every image is checked structurally (ESP32-S3, kota-sensorboard app -descriptor, segment checksum and the appended SHA-256) so a truncated or -corrupted file is refused before anything is written. -""" - -from __future__ import annotations - -import datetime as dt -import hashlib -import json -import shutil -import struct -import tomllib -from dataclasses import dataclass -from pathlib import Path - -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_SIZE = 0xC00 -ESP32S3_CHIP_ID = 9 -IMAGE_MAGIC = 0xE9 -APP_DESC_MAGIC = 0xABCD5432 -FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"} -_MONTHS = {m: i for i, m in enumerate( - ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 1)} - - -class ImageError(ValueError): - pass - - -@dataclass(frozen=True) -class Partition: - label: str - type: int - subtype: int - offset: int - size: int - - -@dataclass(frozen=True) -class AppInfo: - project: str - version: str - built: str # "Jun 29 2026 11:03:32" as in the app descriptor - idf: str - elf_sha: str # hex - - @property - def built_at(self) -> dt.datetime | None: - try: - mon, day, year, hms = self.built.split() - h, m, s = (int(x) for x in hms.split(":")) - return dt.datetime(int(year), _MONTHS[mon], int(day), h, m, s) - except (ValueError, KeyError): - return None - - -@dataclass(frozen=True) -class FirmwareImage: - path: Path - name: str - notes: str - origin: str # "factory" | "developer" - kind: str # "merged" | "app" - sha256: str - flash_size: str - partitions: tuple[Partition, ...] - partition_table: bytes # expected table on the box - app_offset: int # where the app starts inside the file - app_length: int # exact app image length (from its structure) - app: AppInfo - built_by: str = "" # who compiled a developer build (shown in the UI) - - @property - def data(self) -> bytes: - return self.path.read_bytes() - - @property - def is_developer(self) -> bool: - return self.origin == "developer" - - def partition(self, label: str) -> Partition: - for p in self.partitions: - if p.label == label: - return p - raise ImageError(f"partition {label!r} not in image") - - def app_bytes(self) -> bytes: - return self.data[self.app_offset:self.app_offset + self.app_length] - - def blank_otadata(self) -> bytes: - """Erased otadata: the bootloader then boots ota_0.""" - return b"\xff" * self.partition("otadata").size - - def label(self) -> str: - if self.origin == "factory": - tag = "factory" - else: - 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 - -def parse_partition_table(raw: bytes) -> tuple[Partition, ...]: - parts = [] - for i in range(0, len(raw) - 31, 32): - entry = raw[i:i + 32] - magic = entry[:2] - if magic == b"\xaa\x50": - _, ptype, sub, off, size = struct.unpack_from(" int: - """Validate an ESP app image at `offset`; return its exact length. - - Checks the segment checksum and, when the header says so, the appended - SHA-256 - the same checks the bootloader performs before booting it. - """ - if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC: - raise ImageError(f"no firmware image at 0x{offset:x}") - segments = data[offset + 1] - chip_id = struct.unpack_from(" len(data): - raise ImageError("image is truncated (segment header missing)") - _, seg_len = struct.unpack_from(" len(data): - raise ImageError("image is truncated (segment data missing)") - for b in data[pos:pos + seg_len]: - checksum ^= b - pos += seg_len - pos += 15 - ((pos - offset) % 16) # pad so the checksum ends a 16-byte block - if pos >= len(data) or data[pos] != checksum: - raise ImageError("image checksum is wrong - the file is corrupted") - pos += 1 - if hash_appended: - if pos + 32 > len(data): - raise ImageError("image is truncated (SHA-256 missing)") - if hashlib.sha256(data[offset:pos]).digest() != data[pos:pos + 32]: - raise ImageError("image SHA-256 does not match its content - the file is corrupted") - pos += 32 - return pos - offset - - -def _app_info(data: bytes, offset: int) -> AppInfo: - d = data[offset + 0x20:offset + 0x20 + 256] - if len(d) < 256 or struct.unpack_from(" FirmwareImage: - """Validate a merged or app-only image. App-only images need a `reference` - (the factory image) that provides the flash layout.""" - 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": - app_image_length(data, 0) # bootloader must be intact - table = data[PT_OFFSET:PT_OFFSET + PT_SIZE] - parts = parse_partition_table(table) - labels = {p.label for p in parts} - for need in ("nvs", "otadata", "ota_0"): - if need not in labels: - raise ImageError(f"partition table has no {need!r} partition") - ota0 = next(p for p in parts if p.label == "ota_0") - kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?") - elif data[:1] == bytes([IMAGE_MAGIC]): - if reference is None: - raise ImageError("app-only image needs the factory image as layout reference") - kind, app_off = "app", 0 - parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size - ota0 = next(p for p in parts if p.label == "ota_0") - else: - raise ImageError("not an ESP32 firmware file") - length = app_image_length(data, app_off) - if length > ota0.size: - raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)") - app = _app_info(data, app_off) - return FirmwareImage(path, name or path.name, notes, origin, kind, hashlib.sha256(data).hexdigest(), - flash_size, parts, table, app_off, length, app, built_by) - - -# ------------------------------------------------------------- catalogue - -def bundled_images(manifest: Path = MANIFEST) -> list[FirmwareImage]: - """Images shipped with the tool whose file exists and matches its SHA-256. - Factory images first; bundled developer builds keep their origin/built_by.""" - if not manifest.exists(): - return [] - out = [] - for e in tomllib.loads(manifest.read_text(encoding="utf-8")).get("image", []): - path = manifest.parent / e["file"] - if not path.exists(): - continue - origin = e.get("origin", "factory") - if origin not in ("factory", "developer"): - raise ImageError(f"{path.name}: unknown origin {origin!r} in the manifest") - img = inspect_image(path, e.get("name", ""), e.get("notes", ""), origin, built_by=e.get("built_by", "")) - if img.sha256 != e["sha256"].lower(): - raise ImageError(f"{path.name}: SHA-256 does not match the manifest - file changed or corrupt") - out.append(img) - return sorted(out, key=lambda i: i.origin != "factory") - - -def factory_images(manifest: Path = MANIFEST) -> list[FirmwareImage]: - """Only the official factory image(s).""" - return [i for i in bundled_images(manifest) if i.origin == "factory"] - - -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 +"""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 kinds: + * merged - bootloader at 0x0 + partition table + app (merged-binary.bin); + App-only or Full flashing; + * app - the application binary only (build/kota-sensorboard.bin); + App-only flashing, laid out like a merged image in the folder. + +Every image is checked structurally (ESP32-S3, kota-sensorboard app +descriptor, segment checksum and the appended SHA-256) so a truncated or +corrupted file is refused before anything is written. +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import struct +from dataclasses import dataclass +from pathlib import Path + +FIRMWARE_DIR = Path(__file__).resolve().parents[2] / "firmware" + +PT_OFFSET = 0x8000 +PT_SIZE = 0xC00 +ESP32S3_CHIP_ID = 9 +IMAGE_MAGIC = 0xE9 +APP_DESC_MAGIC = 0xABCD5432 +FLASH_SIZES = {0: "1MB", 1: "2MB", 2: "4MB", 3: "8MB", 4: "16MB", 5: "32MB"} +_MONTHS = {m: i for i, m in enumerate( + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 1)} + + +class ImageError(ValueError): + pass + + +@dataclass(frozen=True) +class Partition: + label: str + type: int + subtype: int + offset: int + size: int + + +@dataclass(frozen=True) +class AppInfo: + project: str + version: str + built: str # "Sep 28 2026 15:07:57" as in the app descriptor + idf: str + elf_sha: str # hex + + @property + def built_at(self) -> dt.datetime | None: + try: + mon, day, year, hms = self.built.split() + h, m, s = (int(x) for x in hms.split(":")) + return dt.datetime(int(year), _MONTHS[mon], int(day), h, m, s) + except (ValueError, KeyError): + 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) +class FirmwareImage: + path: Path + kind: str # "merged" | "app" + sha256: str + flash_size: str + partitions: tuple[Partition, ...] + partition_table: bytes # expected table on the box + app_offset: int # where the app starts inside the file + app_length: int # exact app image length (from its structure) + app: AppInfo + + @property + def name(self) -> str: + return self.path.name + + @property + def data(self) -> bytes: + return self.path.read_bytes() + + def partition(self, label: str) -> Partition: + for p in self.partitions: + if p.label == label: + return p + raise ImageError(f"partition {label!r} not in image") + + def app_bytes(self) -> bytes: + return self.data[self.app_offset:self.app_offset + self.app_length] + + def blank_otadata(self) -> bytes: + """Erased otadata: the bootloader then boots ota_0.""" + return b"\xff" * self.partition("otadata").size + + def label(self) -> str: + """Choice text: compile date first (that is what the technician picks by).""" + kind = "" if self.kind == "merged" else ", app only" + return f"Compiled {self.app.compiled} - v{self.app.version} [{self.app.elf_sha[:8]}]{kind} ({self.name})" + + +# ---------------------------------------------------------------- parsing + +def parse_partition_table(raw: bytes) -> tuple[Partition, ...]: + parts = [] + for i in range(0, len(raw) - 31, 32): + entry = raw[i:i + 32] + magic = entry[:2] + if magic == b"\xaa\x50": + _, ptype, sub, off, size = struct.unpack_from(" int: + """Validate an ESP app image at `offset`; return its exact length. + + Checks the segment checksum and, when the header says so, the appended + SHA-256 - the same checks the bootloader performs before booting it. + """ + if len(data) < offset + 24 or data[offset] != IMAGE_MAGIC: + raise ImageError(f"no firmware image at 0x{offset:x}") + segments = data[offset + 1] + chip_id = struct.unpack_from(" len(data): + raise ImageError("image is truncated (segment header missing)") + _, seg_len = struct.unpack_from(" len(data): + raise ImageError("image is truncated (segment data missing)") + for b in data[pos:pos + seg_len]: + checksum ^= b + pos += seg_len + pos += 15 - ((pos - offset) % 16) # pad so the checksum ends a 16-byte block + if pos >= len(data) or data[pos] != checksum: + raise ImageError("image checksum is wrong - the file is corrupted") + pos += 1 + if hash_appended: + if pos + 32 > len(data): + raise ImageError("image is truncated (SHA-256 missing)") + if hashlib.sha256(data[offset:pos]).digest() != data[pos:pos + 32]: + raise ImageError("image SHA-256 does not match its content - the file is corrupted") + pos += 32 + return pos - offset + + +def _app_info(data: bytes, offset: int) -> AppInfo: + d = data[offset + 0x20:offset + 0x20 + 256] + if len(d) < 256 or struct.unpack_from(" FirmwareImage: + """Validate a merged or app-only image. App-only images need a `reference` + (a merged image) that provides the flash layout.""" + 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": + app_image_length(data, 0) # bootloader must be intact + table = data[PT_OFFSET:PT_OFFSET + PT_SIZE] + parts = parse_partition_table(table) + labels = {p.label for p in parts} + for need in ("nvs", "otadata", "ota_0"): + if need not in labels: + raise ImageError(f"partition table has no {need!r} partition") + ota0 = next(p for p in parts if p.label == "ota_0") + kind, app_off, flash_size = "merged", ota0.offset, FLASH_SIZES.get(data[3] >> 4, "?") + elif data[:1] == bytes([IMAGE_MAGIC]): + if reference is None: + 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 + parts, table, flash_size = reference.partitions, reference.partition_table, reference.flash_size + ota0 = next(p for p in parts if p.label == "ota_0") + else: + raise ImageError("not an ESP32 firmware file") + length = app_image_length(data, app_off) + if length > ota0.size: + raise ImageError(f"firmware ({length} bytes) does not fit the ota_0 partition ({ota0.size} bytes)") + app = _app_info(data, app_off) + return FirmwareImage(path, kind, hashlib.sha256(data).hexdigest(), + flash_size, parts, table, app_off, length, app) + + +# ------------------------------------------------------------- catalogue + +def folder_images(folder: Path = FIRMWARE_DIR) -> tuple[list[FirmwareImage], list[str]]: + """(valid images newest-compiled first, problems) for the .bin files in `folder`. + + A broken or foreign file is reported, never offered. The same build + under two file names is offered once. + """ + if not folder.is_dir(): + return [], [f"firmware folder not found: {folder}"] + files = sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == ".bin") + merged: list[FirmwareImage] = [] + app_only: list[Path] = [] + problems: list[str] = [] + for f in files: + try: + merged.append(inspect_image(f)) + except ImageError as exc: + if "app-only" in str(exc): + 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 _newest_first(img: FirmwareImage) -> tuple: + # Unknown compile dates last; merged before app-only for the same date. + t = img.app.built_at + return (t is None, -t.timestamp() if t else 0.0, img.kind != "merged", img.name) diff --git a/kota_tech/matter/payload.py b/kota_tech/matter/payload.py index ce11d91..e95c3ed 100644 --- a/kota_tech/matter/payload.py +++ b/kota_tech/matter/payload.py @@ -1,96 +1,113 @@ -"""Matter onboarding payloads: the "MT:..." QR string and the 11-digit manual code. - -Implements the Matter spec (5.1.3 QR payload, 5.1.4 manual pairing code): -bit fields packed LSB-first, Base38 in 3-byte chunks, Verhoeff check digit. - -The sensorbox firmware uses the Matter SDK example commissionable data -(CONFIG_EXAMPLE_COMMISSIONABLE_DATA_PROVIDER): every v1.0 box advertises -VID 0xFFF1 / PID 0x8000 / discriminator 3840 with passcode 20202021, which is -exactly the sticker (QR "MT:Y.K9042C00KA0648G00", manual 3497-011-2332). -VID/PID/discriminator are taken from the box's own log when available; the -passcode is never logged, so the known default is used. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass - -BASE38 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-." -RENDEZVOUS_BLE = 1 << 1 -DEFAULT_PASSCODE = 20202021 # Matter SDK test passcode used by the v1.0 firmware -DEFAULT = dict(vendor_id=0xFFF1, product_id=0x8000, discriminator=3840) - -# chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0 -_ADVERTISE = re.compile(r"vendorID=(\d+) productID=(\d+) discriminator=(\d+)") - - -@dataclass(frozen=True) -class SetupPayload: - vendor_id: int = DEFAULT["vendor_id"] - product_id: int = DEFAULT["product_id"] - discriminator: int = DEFAULT["discriminator"] - passcode: int = DEFAULT_PASSCODE - rendezvous: int = RENDEZVOUS_BLE - flow: int = 0 # standard commissioning flow - version: int = 0 - from_log: bool = False # VID/PID/discriminator read from this box's log - - def qr_text(self) -> str: - fields = [(self.version, 3), (self.vendor_id, 16), (self.product_id, 16), (self.flow, 2), - (self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)] - value, shift = 0, 0 - for v, bits in fields: - if not 0 <= v < (1 << bits): - raise ValueError(f"field value {v} does not fit in {bits} bits") - value |= v << shift - shift += bits - return "MT:" + _base38(value.to_bytes(shift // 8, "little")) - - def manual_code(self) -> str: - short = self.discriminator >> 8 # upper 4 bits - chunk1 = short >> 2 # vid_pid_present = 0 - chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF) - chunk3 = self.passcode >> 14 - digits = f"{chunk1}{chunk2:05d}{chunk3:04d}" - return digits + str(verhoeff_check(digits)) - - def manual_code_pretty(self) -> str: - c = self.manual_code() - return f"{c[:4]}-{c[4:7]}-{c[7:]}" - - -def _base38(data: bytes) -> str: - out = [] - for i in range(0, len(data), 3): - chunk = data[i:i + 3] - value = int.from_bytes(chunk, "little") - for _ in range({3: 5, 2: 4, 1: 2}[len(chunk)]): - value, rem = divmod(value, 38) - out.append(BASE38[rem]) - return "".join(out) - - -_D = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], - [3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1], - [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], - [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]] -_P = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], - [8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1], - [2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8]] -_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9] - - -def verhoeff_check(digits: str) -> int: - c = 0 - for i, d in enumerate(reversed(digits)): - c = _D[c][_P[(i + 1) % 8][int(d)]] - return _INV[c] - - -def payload_from_log(lines: list[str]) -> SetupPayload: - """Use the box's advertised VID/PID/discriminator if seen; defaults otherwise.""" - for text in reversed(lines): - if m := _ADVERTISE.search(text): - return SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True) - return SetupPayload() +"""Matter onboarding payloads: the "MT:..." QR string and the 11-digit manual code. + +Implements the Matter spec (5.1.3 QR payload, 5.1.4 manual pairing code): +bit fields packed LSB-first, Base38 in 3-byte chunks, Verhoeff check digit. + +The sensorbox firmware uses the Matter SDK example commissionable data +(CONFIG_EXAMPLE_COMMISSIONABLE_DATA_PROVIDER): every v1.0 box advertises +VID 0xFFF1 / PID 0x8000 / discriminator 3840 with passcode 20202021, which is +exactly the sticker (QR "MT:Y.K9042C00KA0648G00", manual 3497-011-2332). +VID/PID/discriminator are taken from the box's own log when available; the +passcode is never logged, so the known default is used. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +BASE38 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-." +RENDEZVOUS_BLE = 1 << 1 +DEFAULT_PASSCODE = 20202021 # Matter SDK test passcode used by the v1.0 firmware +DEFAULT = dict(vendor_id=0xFFF1, product_id=0x8000, discriminator=3840) + +# chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0 +_ADVERTISE = re.compile(r"vendorID=(\d+) productID=(\d+) discriminator=(\d+)") + + +@dataclass(frozen=True) +class SetupPayload: + vendor_id: int = DEFAULT["vendor_id"] + product_id: int = DEFAULT["product_id"] + discriminator: int = DEFAULT["discriminator"] + passcode: int = DEFAULT_PASSCODE + rendezvous: int = RENDEZVOUS_BLE + flow: int = 0 # standard commissioning flow + version: int = 0 + from_log: bool = False # VID/PID/discriminator read from this box's log + qr: str | None = None # exact payload reported by the box ("KOTA: matter qr=MT:...") + manual: str | None = None # exact 11-digit manual code reported by the box + + @property + def from_box(self) -> bool: + return self.qr is not None + + def qr_text(self) -> str: + if self.qr: + return self.qr + fields = [(self.version, 3), (self.vendor_id, 16), (self.product_id, 16), (self.flow, 2), + (self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)] + value, shift = 0, 0 + for v, bits in fields: + if not 0 <= v < (1 << bits): + raise ValueError(f"field value {v} does not fit in {bits} bits") + value |= v << shift + shift += bits + return "MT:" + _base38(value.to_bytes(shift // 8, "little")) + + def manual_code(self) -> str: + if self.manual: + return self.manual + short = self.discriminator >> 8 # upper 4 bits + chunk1 = short >> 2 # vid_pid_present = 0 + chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF) + chunk3 = self.passcode >> 14 + digits = f"{chunk1}{chunk2:05d}{chunk3:04d}" + return digits + str(verhoeff_check(digits)) + + def manual_code_pretty(self) -> str: + c = self.manual_code() + return f"{c[:4]}-{c[4:7]}-{c[7:]}" + + +def _base38(data: bytes) -> str: + out = [] + for i in range(0, len(data), 3): + chunk = data[i:i + 3] + value = int.from_bytes(chunk, "little") + for _ in range({3: 5, 2: 4, 1: 2}[len(chunk)]): + value, rem = divmod(value, 38) + out.append(BASE38[rem]) + return "".join(out) + + +_D = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], + [3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1], + [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], + [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]] +_P = [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], + [8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1], + [2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8]] +_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9] + + +def verhoeff_check(digits: str) -> int: + c = 0 + for i, d in enumerate(reversed(digits)): + c = _D[c][_P[(i + 1) % 8][int(d)]] + return _INV[c] + + +def payload_from_log(lines: list[str], qr: str | None = None, manual: str | None = None) -> SetupPayload: + """Firmware 1.0.16+ reports its exact QR payload and manual code + ("KOTA: matter ... qr=MT:... manual=..."); older firmware: use the + advertised VID/PID/discriminator if seen; defaults otherwise.""" + base = SetupPayload() + for text in reversed(lines): + if m := _ADVERTISE.search(text): + base = SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True) + break + if qr: + return SetupPayload(base.vendor_id, base.product_id, base.discriminator, from_log=True, + qr=qr, manual=manual if manual and manual.isdigit() else None) + return base diff --git a/kota_tech/service/report.py b/kota_tech/service/report.py index 085a7b8..5fb9d80 100644 --- a/kota_tech/service/report.py +++ b/kota_tech/service/report.py @@ -1,101 +1,155 @@ -"""Hand-off of a diagnosis to the KOTA service system (Google Apps Script). - -The tool opens the box's service page in the technician's browser: - #kota-report= -The service page (v1.1.0+) reads the fragment and pre-fills status and note; -the technician checks it, adds the location and saves - so the report is -authored by their own Google login and the tool holds no credentials. -The fragment is never sent to any server (redirect host or Apps Script). -""" - -from __future__ import annotations - -import base64 -import json -import re - -from .. import __version__ -from ..diag.engine import Report, Severity - -DEFAULT_BASE = "https://service.kota-app.com/" -NOTE_LIMIT = 4000 # the service form accepts 5000; leave room for the technician - -# Status names exactly as in the service system (Code.gs STATUSES). -OK = "Działa poprawnie" -CHECK = "Do sprawdzenia" -IN_SERVICE = "W trakcie serwisu" -HARDWARE = "Błąd hardware" -SOFTWARE = "Błąd software" -POWER = "Problem z zasilaniem" -CONNECTIVITY = "Problem z łącznością" - -# Root-finding rule id prefix -> service status (first match wins). -_STATUS_BY_RULE = [ - ("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER), - ("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE), - ("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE), - ("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE), - ("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE), - ("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE), - ("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE), - ("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY), - ("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY), - ("rtc.clock_not_running", CHECK), ("reboot.", CHECK), -] -_MAC = re.compile(r"^[0-9A-F]{12}$") - - -def normalize_mac(mac: str) -> str: - return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper() - - -def service_status(report: Report) -> str: - """Suggested status; the technician can change it in the form.""" - problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING] - if not problems: - return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK - rid = problems[0].finding.rule_id - for prefix, status in _STATUS_BY_RULE: - if rid.startswith(prefix): - return status - return CHECK - - -def service_note(report: Report, limit: int = NOTE_LIMIT) -> str: - lines = [f"[KOTA Technician Tool {__version__}] {report.verdict}", - f"Firmware: {report.firmware.describe()}", - f"Expected LED: {report.led.capitalize()}"] - reached = [label for _, label, t in report.milestones if t is not None] - missing = [label for _, label, t in report.milestones if t is None] - if reached: - lines.append("OK: " + ", ".join(reached)) - if missing: - lines.append("Not reached: " + ", ".join(missing)) - for rc in report.roots: - f = rc.finding - lines.append(f"- [{f.severity.label}] {f.title}") - for c in rc.consequences: - lines.append(f" -> {c.title}") - if report.technician: - lines.append("Technician checks:") - lines += [f" * {t}" for t in report.technician[-12:]] - text = "\n".join(lines) - return text if len(text) <= limit else text[:limit - 20].rstrip() + "\n...(shortened)" - - -def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str: - mac = normalize_mac(mac) - if not _MAC.match(mac): - raise ValueError(f"not a box MAC address: {mac!r}") - payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report), - "tool": f"v{__version__}"} - raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") - base = base if base.endswith("/") else base + "/" - return f"{base}{mac}#kota-report={frag}" - - -def decode_fragment(url: str) -> dict: - """Inverse of report_url (used by tests and for troubleshooting).""" - frag = url.split("#kota-report=", 1)[1] - return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8")) +"""Hand-off of a diagnosis to the KOTA service system (Google Apps Script). + +The tool opens the box's service page in the technician's browser: + #kota-report= +The service page (v1.1.0+) reads the fragment and pre-fills status and note; +the technician checks it, adds the location and saves - so the report is +authored by their own Google login and the tool holds no credentials. +The fragment is never sent to any server (redirect host or Apps Script). +""" + +from __future__ import annotations + +import base64 +import json +import re + +from .. import __version__ +from ..diag.engine import Report, Severity + +DEFAULT_BASE = "https://service.kota-app.com/" +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). +OK = "Działa poprawnie" +CHECK = "Do sprawdzenia" +IN_SERVICE = "W trakcie serwisu" +HARDWARE = "Błąd hardware" +SOFTWARE = "Błąd software" +POWER = "Problem z zasilaniem" +CONNECTIVITY = "Problem z łącznością" + +# Root-finding rule id prefix -> service status (first match wins). +_STATUS_BY_RULE = [ + ("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER), + ("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE), + ("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE), + ("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE), + ("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE), + ("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE), + ("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE), + ("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY), + ("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY), + # 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), +] +_MAC = re.compile(r"^[0-9A-F]{12}$") + + +def normalize_mac(mac: str) -> str: + return re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper() + + +def service_status(report: Report) -> str: + """Suggested status; the technician can change it in the form.""" + problems = [rc for rc in report.roots if rc.severity >= Severity.WARNING] + if not problems: + return OK if any(m == "data_uploaded" and t is not None for m, _, t in report.milestones) else CHECK + rid = problems[0].finding.rule_id + for prefix, status in _STATUS_BY_RULE: + if rid.startswith(prefix): + return status + 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: + """The Diagnosis panel as text: verdict, LED, firmware, box self-report, + start-up progress with times, findings with details and when they were seen. + + Falls back to shorter finding details when the full text does not fit the + service form (consequences' explanations first, then all), then truncates. + """ + for detail in ("all", "roots", "none"): + text = _note(report, detail) + 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: + lines += _finding(rc.finding, "", detail in ("all", "roots")) + for c in rc.consequences: + lines += _finding(c, " ↳ ", detail == "all") + for title, items in (("Seen in earlier boots only", report.earlier), + ("Resolved during this session", report.resolved)): + if items: + lines += ["", f"{title} ({len(items)}):"] + lines += [f" - [{f.severity.label}] {f.title} - {f.seen_text()}" for f in items] + if report.technician: + lines += ["", "Technician checks:"] + lines += [f" * {t}" for t in report.technician[-12:]] + return "\n".join(lines) + + +def _finding(f, prefix: str, detailed: bool) -> list[str]: + ind = " " * len(prefix) + " " + out = [f"{prefix}[{f.severity.label}] {f.title}"] + if detailed: + out.append(f"{ind}What it means: {f.meaning}") + if f.actions: + out.append(f"{ind}What to do:") + out += [f"{ind} {i}. {a}" for i, a in enumerate(f.actions, 1)] + out += [f"{ind}Error {eid}" + (f": {a}" if a else "") for eid, a in f.error_ids.items()] + out.append(f"{ind}{f.seen_text()}") + if f.evidence: + out.append(f"{ind}Log: {f.evidence[-1]}") + return out + + +def report_url(mac: str, report: Report, base: str = DEFAULT_BASE) -> str: + mac = normalize_mac(mac) + if not _MAC.match(mac): + raise ValueError(f"not a box MAC address: {mac!r}") + payload = {"v": 1, "mac": mac, "status": service_status(report), "note": service_note(report), + "tool": f"v{__version__}"} + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + frag = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + base = base if base.endswith("/") else base + "/" + return f"{base}{mac}#kota-report={frag}" + + +def decode_fragment(url: str) -> dict: + """Inverse of report_url (used by tests and for troubleshooting).""" + frag = url.split("#kota-report=", 1)[1] + return json.loads(base64.urlsafe_b64decode(frag + "=" * (-len(frag) % 4)).decode("utf-8")) diff --git a/kota_tech/ui/cheatsheet.py b/kota_tech/ui/cheatsheet.py index 269d615..83eede6 100644 --- a/kota_tech/ui/cheatsheet.py +++ b/kota_tech/ui/cheatsheet.py @@ -1,85 +1,87 @@ -"""Quick-start cheat sheet shown on launch. - -Targets the DEPLOYED firmware: new_system.bin, app "kota-sensorboard" -1.0, built Jun 29 2026 11:03:32, ELF SHA256 45fa9d5b9... (verified on a -live board). That build has no Wi-Fi setup portal / SSID-on-SD support; -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) -LED_TABLE = [ - ("#2e9e44", "Green", "Everything OK.", "Nothing to do."), - ("#8e44ad", "Purple", - "No Internet / InfluxDB or OTA server unreachable.", - "Check the box is commissioned in the Matter app (sticker QR code) and the " - "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", - "SD card missing/unreadable or ENV.TXT incomplete - or a factory reset is " - "in progress (Matter button held 5 s).", - "If nobody pressed the Matter button: the SD card is inside the case - " - "unscrew, re-seat or re-provision it."), - ("#444444", "Off", "Firmware did not start (or crashed very early).", - "Check power switch & USB cable. If the small LED is also dark: re-flash."), -] - -HINTS = [ - "Colour keeps changing / LED goes dark every few seconds = reboot loop. " - "Connect serial and press Reset & capture: the last lines before the " - "reboot name the cause.", - "Small LED next to the RGB LED blinks once per second while the " - "firmware runs. Not blinking = firmware hung or not running.", - "Outside the case (back): power switch, the microphone switch just above it " - "(off = no noise measurements), and the Matter button. Holding the " - "Matter button 5 s factory-resets the box (it forgets its Matter " - "network and must be commissioned again with the sticker QR code).", - "Inside the case (unscrew): RESET and BOOT buttons and the SD card. " - "Normally not needed - the tool resets the board over USB.", - "Gathering messages for the first ~2 minutes are normal " - "(the air-quality sensor warms up for 120 s).", -] - -STEPS = [ - "Switch the sensorbox power switch ON.", - "Connect the laptop to the box's native USB port with a data-capable cable.", - "Open the Serial monitor tab, choose the sensorbox port, press " - "Connect.", - "Press Reset & capture to record a full boot log from the first line.", - "Read the Diagnosis panel on the right, then open Guided troubleshooting " - "and start Quick check: the tool asks what you see and checks the log live.", -] - - -def cheatsheet_html() -> str: - rows = "".join( - f"      {name}" - f"{meaning}{action}" - for c, name, meaning, action in LED_TABLE - ) - steps = "".join(f"
  • {s}
  • " for s in STEPS) - hints = "".join(f"
  • {h}
  • " for h in HINTS) - return f""" -

    Sensorbox quick start

    -
      {steps}
    -

    RGB status LED (always solid colour)

    - - - - - {rows} -
    LEDMost likely meaningFirst action
    -

    Good to know

    -
      {hints}
    - """ +"""Quick-start cheat sheet shown on launch. + +LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md, +status.cpp Status::pattern_for): colour AND pattern name the fault. Older +boxes (v1.0, built Jun 29 2026) show the same colours, always solid. +""" + +# (swatch colour, LED name, pattern -> meaning, first action) +LED_TABLE = [ + ("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."), + ("#1fc8d8", "Cyan", + "breathing: not paired in Matter yet
    fast blink: a phone is connected for pairing.", + "Pair the box in the Matter app (QR sticker or the QR code in the tool)."), + ("#f4f4f4", "White", + "fast blink: SD card or ENV.TXT error
    solid: factory reset (Matter button held 5 s)
    " + "breathing: Wi-Fi setup portal.", + "SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."), + ("#d64541", "Red", + "1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed
    " + "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
    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
    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).", + "Check power switch & USB cable. If the small LED is also dark: re-flash."), +] + +HINTS = [ + "Before a self-restart the LED flashes the error colour 3 times; after the restart it " + "shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press " + "Reset & capture: the tool reads the restart reason from the box.", + "Small LED next to the RGB LED (heartbeat): blinks once per second while online, " + "double-blinks while offline. Dark or steady = firmware hung or not running.", + "Serial monitor: lines starting with KOTA: 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 KOTA diagnostics + errors. Error lines carry a code like " + "[E-SD-03] - the tool shows the recommended action for it.", + "Outside the case (back): power switch, the microphone switch just above it " + "(off = no noise measurements), and the Matter button. Holding the " + "Matter button 5 s factory-resets the box (it forgets its Matter " + "network and must be commissioned again with the sticker QR code).", + "Inside the case (unscrew): RESET and BOOT buttons and the SD card. " + "Normally not needed - the tool resets the board over USB.", + "Flashing uses only the firmware files in the tool's firmware folder; choose the build " + "by its compile date.", +] + +STEPS = [ + "Switch the sensorbox power switch ON.", + "Connect the laptop to the box's native USB port with a data-capable cable.", + "Open the Serial monitor tab, choose the sensorbox port, press " + "Connect.", + "Press Reset & capture to record a full boot log from the first line.", + "Read the Diagnosis panel on the right, then open Guided troubleshooting " + "and start Quick check: the tool asks what you see and checks the log live.", +] + + +def cheatsheet_html() -> str: + rows = "".join( + f"      {name}" + f"{meaning}{action}" + for c, name, meaning, action in LED_TABLE + ) + steps = "".join(f"
  • {s}
  • " for s in STEPS) + hints = "".join(f"
  • {h}
  • " for h in HINTS) + return f""" +

    Sensorbox quick start

    +
      {steps}
    +

    RGB status LED - colour and pattern

    + + + + + {rows} +
    LEDPattern: meaningFirst action
    +

    Good to know

    +
      {hints}
    + """ diff --git a/kota_tech/ui/diag_panel.py b/kota_tech/ui/diag_panel.py index 11c4e4f..184eac5 100644 --- a/kota_tech/ui/diag_panel.py +++ b/kota_tech/ui/diag_panel.py @@ -1,234 +1,252 @@ -"""Diagnostics side panel: renders a diag.engine.Report. - -Layout: verdict banner, expected LED, firmware, start-up 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 PySide6.QtCore import Qt -from PySide6.QtGui import QBrush, QColor -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QLabel, - QListWidget, - QListWidgetItem, - QPushButton, - QSizePolicy, - QSplitter, - QTextBrowser, - QTreeWidget, - QTreeWidgetItem, - QVBoxLayout, - QWidget, -) - -from ..diag.engine import LED, Finding, Report, Severity - -SEV_COLOR = { - Severity.CRITICAL: "#b03a2e", - Severity.ERROR: "#d9534f", - Severity.WARNING: "#c77c02", - Severity.INFO: "#2e6da4", -} -# verdict banner (background, text) by verdict severity; None = healthy -BANNER = { - Severity.CRITICAL: ("#f6d5d1", "#7b1e14"), - Severity.ERROR: ("#f9dedc", "#8a2019"), - Severity.WARNING: ("#fdebc8", "#6b4400"), - None: ("#d8f0dd", "#1b5e2a"), -} -_KEY = Qt.ItemDataRole.UserRole - - -def _heading(text: str) -> QLabel: - lbl = QLabel(text) - f = lbl.font() - f.setBold(True) - lbl.setFont(f) - return lbl - - -def _esc(s: str) -> str: - return s.replace("&", "&").replace("<", "<").replace(">", ">") - - -class DiagnosticsPanel(QWidget): - def __init__(self, parent: QWidget | None = None): - super().__init__(parent) - self.setMinimumWidth(380) - - self.verdict = QLabel(wordWrap=True) - self.verdict.setMargin(10) - vf = self.verdict.font() - vf.setPointSize(vf.pointSize() + 2) - vf.setBold(True) - self.verdict.setFont(vf) - - self.led_swatch = QLabel() - self.led_swatch.setFixedSize(22, 22) - self.led_text = QLabel(wordWrap=True) - led_row = QHBoxLayout() - led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop) - led_row.addWidget(self.led_text, 1) - - self.firmware = QLabel(wordWrap=True) - self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - - self.milestones = QListWidget() - self.milestones.setFrameShape(QFrame.Shape.NoFrame) - self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection) - self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) - - self.tree = QTreeWidget() - self.tree.setHeaderHidden(True) - self.tree.setIndentation(16) - self.tree.setTextElideMode(Qt.TextElideMode.ElideRight) - self.detail = QTextBrowser() - self.detail.setOpenExternalLinks(False) - findings = QSplitter(Qt.Orientation.Vertical) - findings.addWidget(self.tree) - findings.addWidget(self.detail) - findings.setSizes([220, 260]) - - lay = QVBoxLayout(self) - lay.setContentsMargins(4, 0, 0, 0) - lay.addWidget(self.verdict) - lay.addWidget(_heading("Status LED should be")) - lay.addLayout(led_row) - lay.addWidget(_heading("Firmware")) - lay.addWidget(self.firmware) - self.qr_btn = QPushButton("Matter pairing QR code...") - self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone") - lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft) - lay.addWidget(_heading("Start-up progress (current boot)")) - lay.addWidget(self.milestones) - lay.addWidget(_heading("Findings - root causes first; click one for details")) - lay.addWidget(findings, 1) - - self._findings: dict[str, Finding] = {} - self._selected: str | None = None - # key -> expanded, chosen by the user; survives the rebuild on refresh. - self._expanded: dict[str, bool] = {} - self.tree.currentItemChanged.connect(self._on_select) - self.tree.itemCollapsed.connect(lambda it: self._remember(it, False)) - self.tree.itemExpanded.connect(lambda it: self._remember(it, True)) - - # ------------------------------------------------------------------ - def show_report(self, r: Report) -> None: - bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING]) - if r.lines == 0: - bg, fg = "#e6e6e6", "#333333" - self.verdict.setText(r.verdict) - self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}") - - name, colour = LED[r.led] - self.led_swatch.setStyleSheet( - f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}") - ask = "" if r.led == "UNKNOWN" else " - check it matches the box" - reason = f"
    from: {_esc(r.led_reason)}" if r.led_reason else "" - self.led_text.setText(f"{name}{ask}{reason}") - - fw = r.firmware - sd = {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"} - self.firmware.setText(f"{_esc(fw.describe())}
    {sd[fw.supports_sd_wifi]}") - - self.milestones.clear() - for _, label, t in r.milestones: - done = t is not None - it = QListWidgetItem(("✔ " if done else "○ ") + label - + (f" ({t / 1000:.1f} s)" if done else "")) - it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a"))) - self.milestones.addItem(it) - rows = min(max(self.milestones.count(), 1), 15) - self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6) - - self._fill_tree(r) - - def _fill_tree(self, r: Report) -> None: - self.tree.blockSignals(True) - self.tree.clear() - self._findings = {} - if not r.roots: - it = QTreeWidgetItem(["No problems detected in the log so far."]) - it.setForeground(0, QBrush(QColor("#1b7a33"))) - self.tree.addTopLevelItem(it) - for rc in r.roots: - top = self._item(rc.finding, bold=True) - self.tree.addTopLevelItem(top) - for c in rc.consequences: - top.addChild(self._item(c, prefix="↳ ")) - top.setExpanded(self._expanded.get(rc.finding.key, True)) - for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier), - ("Resolved during this session", "grp:resolved", r.resolved)): - if not items: - continue - grp = QTreeWidgetItem([f"{title} ({len(items)})"]) - grp.setData(0, _KEY, key) - grp.setForeground(0, QBrush(QColor("#666666"))) - self.tree.addTopLevelItem(grp) - for f in items: - grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else "")) - grp.setExpanded(self._expanded.get(key, False)) - # Keep the selection across refreshes; default to the top root cause. - target = self._selected if self._selected in self._findings else ( - r.roots[0].finding.key if r.roots else None) - self.tree.blockSignals(False) - self._select(target) - - def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None: - if key := item.data(0, _KEY): - self._expanded[key] = expanded - - def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem: - it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"]) - it.setData(0, _KEY, f.key) - it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333")))) - it.setToolTip(0, f.title) - if bold: - font = it.font(0) - font.setBold(True) - it.setFont(0, font) - self._findings[f.key] = f - return it - - def _select(self, key: str | None) -> None: - if key is None: - self.detail.setHtml("

    Nothing to show yet.

    ") - return - match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive) - for it in match: - if it.data(0, _KEY) == key: - self.tree.setCurrentItem(it) - return - - def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None: - key = cur.data(0, _KEY) if cur else None - f = self._findings.get(key or "") - if f is None: - return - self._selected = key - self.detail.setHtml(_detail_html(f)) - - -def _detail_html(f: Finding) -> str: - colour = SEV_COLOR.get(f.severity, "#333") - actions = "".join(f"
  • {_esc(a)}
  • " for a in f.actions) - seen = f"Seen {f.count}x" - 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 = "
    ".join(_esc(e) for e in f.evidence) - return ( - f"

    {_esc(f.title)}

    " - f"

    What it means: {_esc(f.meaning)}

    " - + (f"

    What to do:

      {actions}
    " if actions else "") - + f"

    {seen}

    " - + (f"

    {evidence}

    " - if evidence else "") - ) +"""Diagnostics side panel: renders a diag.engine.Report. + +Layout: verdict banner, expected LED (colour + pattern + meaning when the +box reports it), firmware, the box's own self-report (KOTA lines), start-up +milestones, a findings tree (root causes; their consequences as children; +titles only) and a wrapped detail pane for the selected finding. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QBrush, QColor +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QSizePolicy, + QSplitter, + QTextBrowser, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from ..diag.engine import LED, Finding, Report, Severity + +SEV_COLOR = { + Severity.CRITICAL: "#b03a2e", + Severity.ERROR: "#d9534f", + Severity.WARNING: "#c77c02", + Severity.INFO: "#2e6da4", +} +# verdict banner (background, text) by verdict severity; None = healthy +BANNER = { + Severity.CRITICAL: ("#f6d5d1", "#7b1e14"), + Severity.ERROR: ("#f9dedc", "#8a2019"), + Severity.WARNING: ("#fdebc8", "#6b4400"), + None: ("#d8f0dd", "#1b5e2a"), +} +_KEY = Qt.ItemDataRole.UserRole +BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")} + + +def _heading(text: str) -> QLabel: + lbl = QLabel(text) + f = lbl.font() + f.setBold(True) + lbl.setFont(f) + return lbl + + +def _esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +class DiagnosticsPanel(QWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setMinimumWidth(380) + + self.verdict = QLabel(wordWrap=True) + self.verdict.setMargin(10) + vf = self.verdict.font() + vf.setPointSize(vf.pointSize() + 2) + vf.setBold(True) + self.verdict.setFont(vf) + + self.led_swatch = QLabel() + self.led_swatch.setFixedSize(22, 22) + self.led_text = QLabel(wordWrap=True) + led_row = QHBoxLayout() + led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop) + led_row.addWidget(self.led_text, 1) + + self.firmware = QLabel(wordWrap=True) + 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.setFrameShape(QFrame.Shape.NoFrame) + self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection) + self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) + + self.tree = QTreeWidget() + self.tree.setHeaderHidden(True) + self.tree.setIndentation(16) + self.tree.setTextElideMode(Qt.TextElideMode.ElideRight) + self.detail = QTextBrowser() + self.detail.setOpenExternalLinks(False) + findings = QSplitter(Qt.Orientation.Vertical) + findings.addWidget(self.tree) + findings.addWidget(self.detail) + findings.setSizes([220, 260]) + + lay = QVBoxLayout(self) + lay.setContentsMargins(4, 0, 0, 0) + lay.addWidget(self.verdict) + lay.addWidget(_heading("Status LED should be")) + lay.addLayout(led_row) + lay.addWidget(_heading("Firmware")) + lay.addWidget(self.firmware) + self.qr_btn = QPushButton("Matter pairing QR code...") + self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone") + lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft) + lay.addWidget(self.box_title) + lay.addWidget(self.box) + lay.addWidget(_heading("Start-up progress (current boot)")) + lay.addWidget(self.milestones) + lay.addWidget(_heading("Findings - root causes first; click one for details")) + lay.addWidget(findings, 1) + + self._findings: dict[str, Finding] = {} + self._selected: str | None = None + # key -> expanded, chosen by the user; survives the rebuild on refresh. + self._expanded: dict[str, bool] = {} + self.tree.currentItemChanged.connect(self._on_select) + self.tree.itemCollapsed.connect(lambda it: self._remember(it, False)) + self.tree.itemExpanded.connect(lambda it: self._remember(it, True)) + + # ------------------------------------------------------------------ + def show_report(self, r: Report) -> None: + bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING]) + if r.lines == 0: + bg, fg = "#e6e6e6", "#333333" + self.verdict.setText(r.verdict) + self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}") + + name, colour = LED[r.led] + self.led_swatch.setStyleSheet( + f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}") + ask = "" if r.led == "UNKNOWN" else " - check it matches the box" + pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else "" + meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning else "" + source = " (reported by the box)" if r.led_exact else "" + reason = f"
    from: {_esc(r.led_reason)}" if r.led_reason else "" + self.led_text.setText(f"{name}{pattern}{meaning}{source}{ask}{reason}") + + fw = r.firmware + sd = {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"} + self.firmware.setText(f"{_esc(fw.describe())}
    {sd[fw.supports_sd_wifi]}") + + self.box_title.setVisible(bool(r.box)) + self.box.setVisible(bool(r.box)) + self.box.setText("" + "".join( + f"" + f"" + for topic, text, st in r.box) + "
    {BOX_STATE[st][1]} {_esc(topic)} {_esc(text)}
    ") + + self.milestones.clear() + for _, label, t in r.milestones: + done = t is not None + it = QListWidgetItem(("✔ " if done else "○ ") + label + + (f" ({t / 1000:.1f} s)" if done else "")) + it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a"))) + self.milestones.addItem(it) + rows = min(max(self.milestones.count(), 1), 15) + self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6) + + self._fill_tree(r) + + def _fill_tree(self, r: Report) -> None: + self.tree.blockSignals(True) + self.tree.clear() + self._findings = {} + if not r.roots: + it = QTreeWidgetItem(["No problems detected in the log so far."]) + it.setForeground(0, QBrush(QColor("#1b7a33"))) + self.tree.addTopLevelItem(it) + for rc in r.roots: + top = self._item(rc.finding, bold=True) + self.tree.addTopLevelItem(top) + for c in rc.consequences: + top.addChild(self._item(c, prefix="↳ ")) + top.setExpanded(self._expanded.get(rc.finding.key, True)) + for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier), + ("Resolved during this session", "grp:resolved", r.resolved)): + if not items: + continue + grp = QTreeWidgetItem([f"{title} ({len(items)})"]) + grp.setData(0, _KEY, key) + grp.setForeground(0, QBrush(QColor("#666666"))) + self.tree.addTopLevelItem(grp) + for f in items: + grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else "")) + grp.setExpanded(self._expanded.get(key, False)) + # Keep the selection across refreshes; default to the top root cause. + target = self._selected if self._selected in self._findings else ( + r.roots[0].finding.key if r.roots else None) + self.tree.blockSignals(False) + self._select(target) + + def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None: + if key := item.data(0, _KEY): + self._expanded[key] = expanded + + def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem: + it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"]) + it.setData(0, _KEY, f.key) + it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333")))) + it.setToolTip(0, f.title) + if bold: + font = it.font(0) + font.setBold(True) + it.setFont(0, font) + self._findings[f.key] = f + return it + + def _select(self, key: str | None) -> None: + if key is None: + self.detail.setHtml("

    Nothing to show yet.

    ") + return + match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive) + for it in match: + if it.data(0, _KEY) == key: + self.tree.setCurrentItem(it) + return + + def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None: + key = cur.data(0, _KEY) if cur else None + f = self._findings.get(key or "") + if f is None: + return + self._selected = key + self.detail.setHtml(_detail_html(f)) + + +def _detail_html(f: Finding) -> str: + colour = SEV_COLOR.get(f.severity, "#333") + actions = "".join(f"
  • {_esc(a)}
  • " for a in f.actions) + seen = _esc(f.seen_text()) + evidence = "
    ".join(_esc(e) for e in f.evidence) + codes = "".join(f"
  • {_esc(eid)}" + (f" - {_esc(a)}" if a else "") + "
  • " + for eid, a in f.error_ids.items()) + return ( + f"

    {_esc(f.title)}

    " + f"

    What it means: {_esc(f.meaning)}

    " + + (f"

    What to do:

      {actions}
    " if actions else "") + + (f"

    Firmware error codes (error_ids.csv):

      {codes}
    " if codes else "") + + f"

    {seen}

    " + + (f"

    {evidence}

    " + if evidence else "") + ) diff --git a/kota_tech/ui/flash_tab.py b/kota_tech/ui/flash_tab.py index 4700d4f..b963f33 100644 --- a/kota_tech/ui/flash_tab.py +++ b/kota_tech/ui/flash_tab.py @@ -1,398 +1,364 @@ -"""Flash firmware tab: factory image or imported developer builds; App-only by default.""" - -from __future__ import annotations - -import datetime as dt -import tempfile -from pathlib import Path - -from PySide6.QtCore import QObject, Qt, QTimer, Signal -from PySide6.QtWidgets import ( - QButtonGroup, - QCheckBox, - QFileDialog, - QInputDialog, - QComboBox, - QGroupBox, - QHBoxLayout, - QLabel, - QMessageBox, - QPlainTextEdit, - QProgressBar, - QPushButton, - QRadioButton, - QVBoxLayout, - QWidget, -) - -from ..core.session import SESSIONS_DIR -from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, bundled_images -from ..flash.plan import Mode, build_plan -from ..flash.runner import FlashRunner, Result -from ..serialio.ports import PortInfo, list_serial_ports - -BACKUP_DIR = SESSIONS_DIR.parent / "backups" -VERIFY_TIMEOUT_MS = 45_000 - -HELP = ( - "If flashing cannot connect: use a data-capable USB cable directly on the laptop, close " - "other programs using the port, then retry. Last resort (case must be opened): hold BOOT, " - "press and release RESET, release BOOT, and press Start again." -) - - -class _Bridge(QObject): - step = Signal(int, str) - progress = Signal(int) - output = Signal(str) - done = Signal(object) - - -class FlashTab(QWidget): - def __init__(self, serial_tab, parent: QWidget | None = None): - super().__init__(parent) - self.serial_tab = serial_tab - self._images: list[FirmwareImage] = [] - self._ports: list[PortInfo] = [] # before any signal can call _update() - self._runner: FlashRunner | None = None - self._port: PortInfo | None = None - self._image: FirmwareImage | None = None - - # 1. box - box = QGroupBox("1. Sensorbox") - self.port_combo = QComboBox(minimumWidth=360) - refresh = QPushButton("Refresh") - refresh.clicked.connect(self.refresh_ports) - self.box_fw = QLabel(wordWrap=True) - bl = QVBoxLayout(box) - row = QHBoxLayout() - row.addWidget(self.port_combo) - row.addWidget(refresh) - row.addStretch(1) - bl.addLayout(row) - bl.addWidget(self.box_fw) - - # 2. firmware - fwbox = QGroupBox("2. Firmware") - self.image_combo = QComboBox(minimumWidth=360) - self.import_btn = QPushButton("Import firmware file...") - self.import_btn.setToolTip("Add a newer build the firmware team sent you (.bin)") - self.import_btn.clicked.connect(self._import) - self.remove_btn = QPushButton("Remove from list") - self.remove_btn.clicked.connect(self._remove) - self.image_info = QLabel(wordWrap=True) - self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - fl = QVBoxLayout(fwbox) - fl.addWidget(self.image_combo) - brow = QHBoxLayout() - brow.addWidget(self.import_btn) - brow.addWidget(self.remove_btn) - brow.addStretch(1) - fl.addLayout(brow) - fl.addWidget(self.image_info) - self._library = FirmwareLibrary() - self._reference: FirmwareImage | None = None - self._problems: list[str] = [] - - # 3. mode - modebox = QGroupBox("3. What to write") - self.rb_app = QRadioButton("App only (recommended for repairs)") - self.rb_full = QRadioButton("Full factory image (new or completely wiped boards)") - self.mode_group = QButtonGroup(self) - self.mode_group.addButton(self.rb_app) - self.mode_group.addButton(self.rb_full) - self.rb_app.setChecked(True) - app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the " - "settings stored on the box.", wordWrap=True) - full_note = QLabel("Writes everything like the factory. Erases the Matter pairing and the OTA " - "registration - the box must be paired again afterwards.", wordWrap=True) - for n in (app_note, full_note): - n.setContentsMargins(24, 0, 0, 6) - n.setStyleSheet("color:#555") - self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased") - self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)") - ml = QVBoxLayout(modebox) - for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup): - ml.addWidget(w) - - # 4. run - runbox = QGroupBox("4. Flash") - self.plan_label = QLabel(wordWrap=True) - self.start_btn = QPushButton("Start flashing") - self.start_btn.setMinimumHeight(36) - self.start_btn.clicked.connect(self._start) - self.step_label = QLabel() - self.bar = QProgressBar() - self.result = QLabel(wordWrap=True) - self.result.setMargin(8) - self.result.setTextFormat(Qt.TextFormat.RichText) - self.details_btn = QPushButton("Show esptool output") - self.details_btn.setCheckable(True) - self.output = QPlainTextEdit(readOnly=True) - self.output.setVisible(False) - self.details_btn.toggled.connect(self.output.setVisible) - rl = QVBoxLayout(runbox) - for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output): - rl.addWidget(w) - help_lbl = QLabel(HELP, wordWrap=True) - help_lbl.setStyleSheet("color:#555") - rl.addWidget(help_lbl) - rl.addStretch(1) # keep the run column packed at the top - - left = QVBoxLayout() - for w in (box, fwbox, modebox): - left.addWidget(w) - left.addStretch(1) - lay = QHBoxLayout(self) - lay.addLayout(left, 1) - lay.addWidget(runbox, 1) - - self._bridge = _Bridge() - self._bridge.step.connect(self._on_step) - self._bridge.progress.connect(self.bar.setValue) - self._bridge.output.connect(self.output.appendPlainText) - self._bridge.done.connect(self._on_done) - self._verify_timer = QTimer(self, interval=500) - self._verify_timer.timeout.connect(self._check_verify) - - for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled): - sig.connect(self._update) - self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full - self.image_combo.currentIndexChanged.connect(self._update) - self.port_combo.currentIndexChanged.connect(self._update) - - self._load_images() - self.refresh_ports() - self._show_result("", None) - - # ------------------------------------------------------------ setup - def _load_images(self, select_sha: str | None = None) -> None: - self._problems = [] - try: - bundled = bundled_images() - 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.clear() - for img in self._images: - self.image_combo.addItem(img.label()) - if not self._images: - self.image_combo.addItem("No firmware image available - import one") - idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), None) - if idx is None: - idx = len(dev) if factory else 0 # default: the factory image - self.image_combo.setCurrentIndex(idx) - self.image_combo.blockSignals(False) - self._update() - - def _import(self) -> 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() - if img and img.is_developer and QMessageBox.question( - 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: - current = self.port_combo.currentData() - self._ports = list_serial_ports() - self.port_combo.clear() - for p in self._ports: - self.port_combo.addItem(p.label, p.device) - if not self._ports: - self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) - self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0)) - self._update() - - def showEvent(self, event): # noqa: N802 - refresh when the tab is opened - self.refresh_ports() - super().showEvent(event) - - # ------------------------------------------------------------ state - def _selected_image(self) -> FirmwareImage | None: - i = self.image_combo.currentIndex() - return self._images[i] if 0 <= i < len(self._images) else None - - def _selected_port(self) -> PortInfo | None: - dev = self.port_combo.currentData() - return next((p for p in self._ports if p.device == dev), None) - - def _update(self) -> None: - img = self._selected_image() - info = "" - if img: - a = img.app - origin = ("Factory image (bundled with the tool)" if not img.is_developer else - "Developer build - only flash builds you received " - "from the firmware team") - kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only" - info = (f"{origin}
    {a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}, {kind}
    " - f"build {a.elf_sha[:9]} · file SHA-256 {img.sha256[:16]}... " - f"· checksum OK" + (f"
    {img.notes}" if img.notes else "")) - if self._problems: - info += ("
    Not offered: " - + "; ".join(self._problems) + "") - 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. - full_ok = bool(img and img.kind == "merged") - self.rb_full.setEnabled(full_ok and self._runner is None) - if not full_ok and self.rb_full.isChecked(): - self.rb_app.setChecked(True) - self.box_fw.setText(self._box_text(img)) - - self.confirm_full.setVisible(self.rb_full.isChecked()) - ok = bool(img and self._selected_port() and self._runner is None - and (self.rb_app.isChecked() or self.confirm_full.isChecked())) - self.start_btn.setEnabled(ok) - if img and self._selected_port(): - mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL - steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else [] - if mode == Mode.APP_ONLY: - steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"] - else: - steps += ["Write the complete factory image (~25 s)"] - steps += ["Restart the box and verify the new firmware boots"] - self.plan_label.setText("Plan:
      " + "".join(f"
    1. {s}
    2. " for s in steps) + "
    ") - else: - self.plan_label.setText("Select a sensorbox and a firmware image.") - - def _box_text(self, img: FirmwareImage | None) -> str: - fw = self.serial_tab.engine.firmware - if not fw.elf_sha: - 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 "") - if not img: - return msg - if fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha): - return msg + "
    It already has this firmware. Re-flash only if you suspect the firmware is damaged." - box_built, img_built = _parse_built(fw.built), img.app.built_at - if box_built and img_built and img_built > box_built: - return msg + f"
    The selected firmware is newer (built {img.app.built})." - if box_built and img_built and img_built < box_built: - return msg + ("
    The selected firmware is OLDER than the box's (the box was " - "probably updated over OTA). The box updates itself again once online.") - return msg + "
    The box runs a different build." - - # ------------------------------------------------------------ flash - def _start(self) -> None: - img, port = self._selected_image(), self._selected_port() - if not img or not port: - return - 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 - else "the COMPLETE factory image (pairing and OTA registration are erased)") - if QMessageBox.question(self, "Flash firmware", - f"Write {what}\n\nImage: {img.name}\nBox: {port.label}\n\n" - "Keep the box powered and the USB cable connected until it finishes.", - QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel - ) != QMessageBox.StandardButton.Ok: - return - backup_to = None - if self.backup.isChecked(): - BACKUP_DIR.mkdir(parents=True, exist_ok=True) - stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") - backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin" - plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to) - - self._port, self._image = port, img - self.serial_tab.release_port() # esptool needs the port exclusively - self.output.clear() - self.output.appendPlainText("\n".join(plan.summary())) - self._show_result("", None) - self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit, - self._bridge.output.emit, self._bridge.done.emit) - self._set_busy(True) - self._runner.start() - - 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): - w.setEnabled(not busy) - self._update() - - def _on_step(self, i: int, title: str) -> None: - n = len(self._runner.plan.steps) if self._runner else 0 - self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}") - - def _on_done(self, res: Result) -> None: - self._runner = None - if not res.ok: - self._set_busy(False) - self.step_label.setText("Stopped.") - hint = f"

    {res.hint}

    " if res.hint else "" - self._show_result(f"Flashing failed. {res.message}{hint}", "error") - self.details_btn.setChecked(True) - return - self.step_label.setText("Final step: restarting the box and checking which firmware it boots...") - self.bar.setValue(0) - self._show_result("Firmware written. Waiting for the box to start...", "info") - self.serial_tab.connect_port(self._port, capture=True) - self._verify_ms = 0 - self._verify_timer.start() - - def _check_verify(self) -> None: - self._verify_ms += self._verify_timer.interval() - self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS))) - fw = self.serial_tab.engine.firmware - want = self._image.app.elf_sha[:9] - done = fw.elf_sha is not None and fw.elf_sha.startswith(want) - if done or self._verify_ms >= VERIFY_TIMEOUT_MS: - self._verify_timer.stop() - self._set_busy(False) - self.bar.setValue(100) - if done: - self.step_label.setText("Done.") - self._show_result(f"Success. The box boots the new firmware (build {want}). " - "Check the Diagnosis panel in the Serial monitor.", "ok") - else: - seen = fw.elf_sha[:9] if fw.elf_sha else "no boot seen" - self._show_result(f"Written, but not verified. Expected build {want}, saw: {seen}. " - "Open the Serial monitor and press Reset & capture.", "warning") - - def _show_result(self, html: str, kind: str | None) -> None: - colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"), - "warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")} - self.result.setVisible(bool(html)) - if html: - border, bg = colours[kind or "info"] - self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}") - self.result.setText(html) - - -def _parse_built(s: str | None) -> dt.datetime | None: - """'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime.""" - if not s: - return None - try: - return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S") - except ValueError: - return None +"""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 + +import datetime as dt +import tempfile +from pathlib import Path + +from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QComboBox, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QPushButton, + QRadioButton, + QVBoxLayout, + QWidget, +) + +from ..core.session import SESSIONS_DIR +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.runner import FlashRunner, Result +from ..serialio.ports import PortInfo, list_serial_ports + +BACKUP_DIR = SESSIONS_DIR.parent / "backups" +VERIFY_TIMEOUT_MS = 45_000 + +HELP = ( + "If flashing cannot connect: use a data-capable USB cable directly on the laptop, close " + "other programs using the port, then retry. Last resort (case must be opened): hold BOOT, " + "press and release RESET, release BOOT, and press Start again." +) + + +class _Bridge(QObject): + step = Signal(int, str) + progress = Signal(int) + output = Signal(str) + done = Signal(object) + + +class FlashTab(QWidget): + def __init__(self, serial_tab, parent: QWidget | None = None): + super().__init__(parent) + self.serial_tab = serial_tab + self._images: list[FirmwareImage] = [] + self._ports: list[PortInfo] = [] # before any signal can call _update() + self._runner: FlashRunner | None = None + self._port: PortInfo | None = None + self._image: FirmwareImage | None = None + + # 1. box + box = QGroupBox("1. Sensorbox") + self.port_combo = QComboBox(minimumWidth=360) + refresh = QPushButton("Refresh") + refresh.clicked.connect(self.refresh_ports) + self.box_fw = QLabel(wordWrap=True) + bl = QVBoxLayout(box) + row = QHBoxLayout() + row.addWidget(self.port_combo) + row.addWidget(refresh) + row.addStretch(1) + bl.addLayout(row) + bl.addWidget(self.box_fw) + + # 2. firmware - only files in the tool's firmware/ folder + fwbox = QGroupBox("2. Firmware (by compile date)") + self.image_combo = QComboBox(minimumWidth=360) + self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}") + self.reload_btn = QPushButton("Refresh list") + self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)") + self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha())) + 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.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + fl = QVBoxLayout(fwbox) + fl.addWidget(self.image_combo) + brow = QHBoxLayout() + brow.addWidget(self.reload_btn) + brow.addWidget(self.folder_btn) + brow.addStretch(1) + fl.addLayout(brow) + fl.addWidget(self.image_info) + self._problems: list[str] = [] + + # 3. mode + modebox = QGroupBox("3. What to write") + self.rb_app = QRadioButton("App only (recommended for repairs)") + self.rb_full = QRadioButton("Full image (new or completely wiped boards)") + self.mode_group = QButtonGroup(self) + self.mode_group.addButton(self.rb_app) + self.mode_group.addButton(self.rb_full) + self.rb_app.setChecked(True) + app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the " + "settings stored on the box.", wordWrap=True) + full_note = QLabel("Writes everything like the factory. Erases the Matter pairing and the OTA " + "registration - the box must be paired again afterwards.", wordWrap=True) + for n in (app_note, full_note): + n.setContentsMargins(24, 0, 0, 6) + n.setStyleSheet("color:#555") + self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased") + self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)") + ml = QVBoxLayout(modebox) + for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup): + ml.addWidget(w) + + # 4. run + runbox = QGroupBox("4. Flash") + self.plan_label = QLabel(wordWrap=True) + self.start_btn = QPushButton("Start flashing") + self.start_btn.setMinimumHeight(36) + self.start_btn.clicked.connect(self._start) + self.step_label = QLabel() + self.bar = QProgressBar() + self.result = QLabel(wordWrap=True) + self.result.setMargin(8) + self.result.setTextFormat(Qt.TextFormat.RichText) + self.details_btn = QPushButton("Show esptool output") + self.details_btn.setCheckable(True) + self.output = QPlainTextEdit(readOnly=True) + self.output.setVisible(False) + self.details_btn.toggled.connect(self.output.setVisible) + rl = QVBoxLayout(runbox) + for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output): + rl.addWidget(w) + help_lbl = QLabel(HELP, wordWrap=True) + help_lbl.setStyleSheet("color:#555") + rl.addWidget(help_lbl) + rl.addStretch(1) # keep the run column packed at the top + + left = QVBoxLayout() + for w in (box, fwbox, modebox): + left.addWidget(w) + left.addStretch(1) + lay = QHBoxLayout(self) + lay.addLayout(left, 1) + lay.addWidget(runbox, 1) + + self._bridge = _Bridge() + self._bridge.step.connect(self._on_step) + self._bridge.progress.connect(self.bar.setValue) + self._bridge.output.connect(self.output.appendPlainText) + self._bridge.done.connect(self._on_done) + self._verify_timer = QTimer(self, interval=500) + self._verify_timer.timeout.connect(self._check_verify) + + for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled): + sig.connect(self._update) + self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full + self.image_combo.currentIndexChanged.connect(self._update) + self.port_combo.currentIndexChanged.connect(self._update) + + self._load_images() + self.refresh_ports() + self._show_result("", None) + + # ------------------------------------------------------------ setup + def _load_images(self, select_sha: str | None = None) -> None: + self._images, self._problems = folder_images() + for img in self._images: # lets the Serial monitor show a box build's compile date + register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name) + self.image_combo.blockSignals(True) + self.image_combo.clear() + for img in self._images: + self.image_combo.addItem(img.label()) + if not self._images: + self.image_combo.addItem("No firmware file in the firmware folder") + idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0) + self.image_combo.setCurrentIndex(idx) # default: newest compile date + self.image_combo.blockSignals(False) + self._update() + + def _selected_sha(self) -> str | None: + img = self._selected_image() + return img.sha256 if img else None + + def refresh_ports(self) -> None: + current = self.port_combo.currentData() + self._ports = list_serial_ports() + self.port_combo.clear() + for p in self._ports: + self.port_combo.addItem(p.label, p.device) + if not self._ports: + self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) + self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0)) + self._update() + + def showEvent(self, event): # noqa: N802 - refresh when the tab is opened + self.refresh_ports() + super().showEvent(event) + + # ------------------------------------------------------------ state + def _selected_image(self) -> FirmwareImage | None: + i = self.image_combo.currentIndex() + return self._images[i] if 0 <= i < len(self._images) else None + + def _selected_port(self) -> PortInfo | None: + dev = self.port_combo.currentData() + return next((p for p in self._ports if p.device == dev), None) + + def _update(self) -> None: + img = self._selected_image() + info = "" + if img: + a = img.app + kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only" + info = (f"Compiled {a.built} · {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}
    " + f"file firmware/{img.name} · build {a.elf_sha[:8]} · " + f"SHA-256 {img.sha256[:16]}... · checksum OK") + else: + info = (f"Put the firmware .bin file from the firmware team into
    {FIRMWARE_DIR}
    " + "and press Refresh list. Only files in this folder can be flashed.") + if self._problems: + info += ("
    Not offered: " + + "; ".join(self._problems) + "") + self.image_info.setText(info) + # An app-only file has no bootloader/layout: Full mode is impossible. + full_ok = bool(img and img.kind == "merged") + self.rb_full.setEnabled(full_ok and self._runner is None) + if not full_ok and self.rb_full.isChecked(): + self.rb_app.setChecked(True) + self.box_fw.setText(self._box_text(img)) + + self.confirm_full.setVisible(self.rb_full.isChecked()) + ok = bool(img and self._selected_port() and self._runner is None + and (self.rb_app.isChecked() or self.confirm_full.isChecked())) + self.start_btn.setEnabled(ok) + if img and self._selected_port(): + mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL + steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else [] + if mode == Mode.APP_ONLY: + steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"] + else: + steps += ["Write the complete image (~25 s)"] + steps += ["Restart the box and verify the new firmware boots"] + self.plan_label.setText("Plan:
      " + "".join(f"
    1. {s}
    2. " for s in steps) + "
    ") + else: + self.plan_label.setText("Select a sensorbox and a firmware image.") + + def _box_text(self, img: FirmwareImage | None) -> str: + fw = self.serial_tab.engine.firmware + if not fw.elf_sha: + 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()}" + if not img: + return msg + if same_build(fw.elf_sha, img.app.elf_sha): + return msg + "
    It already has this firmware. Re-flash only if you suspect the firmware is damaged." + 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: + return msg + f"
    The selected firmware is newer (built {img.app.built})." + if box_built and img_built and img_built < box_built: + return msg + ("
    The selected firmware is OLDER than the box's (the box was " + "probably updated over OTA). The box updates itself again once online.") + return msg + "
    The box runs a different build." + + # ------------------------------------------------------------ flash + def _start(self) -> None: + img, port = self._selected_image(), self._selected_port() + if not img or not port: + return + 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 + else "the COMPLETE image (pairing and OTA registration are erased)") + if QMessageBox.question(self, "Flash firmware", + 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.", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel + ) != QMessageBox.StandardButton.Ok: + return + backup_to = None + if self.backup.isChecked(): + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin" + plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to) + + self._port, self._image = port, img + self.serial_tab.release_port() # esptool needs the port exclusively + self.output.clear() + self.output.appendPlainText("\n".join(plan.summary())) + self._show_result("", None) + self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit, + self._bridge.output.emit, self._bridge.done.emit) + self._set_busy(True) + self._runner.start() + + 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.reload_btn): + w.setEnabled(not busy) + self._update() + + def _on_step(self, i: int, title: str) -> None: + n = len(self._runner.plan.steps) if self._runner else 0 + self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}") + + def _on_done(self, res: Result) -> None: + self._runner = None + if not res.ok: + self._set_busy(False) + self.step_label.setText("Stopped.") + hint = f"

    {res.hint}

    " if res.hint else "" + self._show_result(f"Flashing failed. {res.message}{hint}", "error") + self.details_btn.setChecked(True) + return + self.step_label.setText("Final step: restarting the box and checking which firmware it boots...") + self.bar.setValue(0) + self._show_result("Firmware written. Waiting for the box to start...", "info") + self.serial_tab.connect_port(self._port, capture=True) + self._verify_ms = 0 + self._verify_timer.start() + + def _check_verify(self) -> None: + self._verify_ms += self._verify_timer.interval() + self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS))) + fw = self.serial_tab.engine.firmware + want = self._image.app.elf_sha[:8] + done = same_build(fw.elf_sha, self._image.app.elf_sha) + if done or self._verify_ms >= VERIFY_TIMEOUT_MS: + self._verify_timer.stop() + self._set_busy(False) + self.bar.setValue(100) + if done: + self.step_label.setText("Done.") + self._show_result(f"Success. The box boots the new firmware (build {want}). " + "Check the Diagnosis panel in the Serial monitor.", "ok") + else: + seen = fw.elf_sha[:8] if fw.elf_sha else "no boot seen" + self._show_result(f"Written, but not verified. Expected build {want}, saw: {seen}. " + "Open the Serial monitor and press Reset & capture.", "warning") + + def _show_result(self, html: str, kind: str | None) -> None: + colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"), + "warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")} + self.result.setVisible(bool(html)) + if html: + border, bg = colours[kind or "info"] + self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}") + self.result.setText(html) + + +def _parse_built(s: str | None) -> dt.datetime | None: + """'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime.""" + if not s: + return None + try: + return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S") + except ValueError: + return None diff --git a/kota_tech/ui/main_window.py b/kota_tech/ui/main_window.py index 4edecf6..6ca784b 100644 --- a/kota_tech/ui/main_window.py +++ b/kota_tech/ui/main_window.py @@ -1,415 +1,424 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal -from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor -from PySide6.QtWidgets import ( - QCheckBox, - QComboBox, - QFileDialog, - QHBoxLayout, - QInputDialog, - QLabel, - QMainWindow, - QMessageBox, - QPlainTextEdit, - QPushButton, - QSplitter, - QTabWidget, - QTextBrowser, - QVBoxLayout, - QWidget, -) - -from .. import __version__ -from ..core.logfile import read_log_file -from ..core.logline import LineKind, LogLine, host_event -from ..core.session import SessionLog -from ..diag.engine import DiagnosticsEngine -from ..diag.guides import GuideRunner -from ..serialio.manager import SerialManager, State -from ..serialio.ports import PortInfo, list_serial_ports -from ..matter.payload import SetupPayload, payload_from_log -from ..service.report import DEFAULT_BASE, normalize_mac, report_url -from .cheatsheet import cheatsheet_html -from .diag_panel import DiagnosticsPanel -from .flash_tab import FlashTab -from .matter_qr import MatterQrDialog -from .guide_panel import GuidePanel -from .sd_tab import SdTab - -MAX_LINES = 20000 -LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}} -COLORS = { - "E": "#ff6b6b", - "W": "#f5c451", - "I": "#d4d4d4", - LineKind.BOOT: "#6fc3ff", - LineKind.RESET: "#6fc3ff", - LineKind.PANIC: "#ff4dd2", - LineKind.HOST: "#7ee07e", - LineKind.RAW: "#a8a8a8", -} - - -class _Bridge(QObject): - """Marshals SerialManager callbacks (reader thread) onto the UI thread.""" - - line = Signal(object) - state = Signal(object, str) - - -class SerialTab(QWidget): - def __init__(self, parent: QWidget | None = None): - super().__init__(parent) - self._bridge = _Bridge() - self._bridge.line.connect(self._on_line) - self._bridge.state.connect(self._on_state) - self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit) - self._session = SessionLog() - self._ports: list[PortInfo] = [] - self._counts = {"E": 0, "W": 0, "boots": 0} - self._engine = DiagnosticsEngine() - self._diag_dirty = True - self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log) - - # --- controls - self.port_combo = QComboBox(minimumWidth=320) - self.all_ports = QCheckBox("Show all ports") - self.refresh_btn = QPushButton("Refresh") - self.connect_btn = QPushButton("Connect") - self.reset_btn = QPushButton("Reset && capture") - self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line") - self.filter_combo = QComboBox() - self.filter_combo.addItems(LEVEL_FILTERS) - self.clear_btn = QPushButton("Clear view") - self.save_btn = QPushButton("Save view as...") - self.folder_btn = QPushButton("Open logs folder") - self.analyze_btn = QPushButton("Analyze log file...") - self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it") - self.copy_btn = QPushButton("Copy report") - self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)") - self.service_btn = QPushButton("Service report...") - self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis " - "filled in; check it and press Save there") - self._settings = QSettings("KOTA", "TechnicianTool") - self.auto_service = QCheckBox("Open service report on disconnect") - self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool)) - self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on)) - self._active_port: PortInfo | None = None - - row1 = QHBoxLayout() - for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn, - self.connect_btn, self.reset_btn): - row1.addWidget(w) - row1.addStretch(1) - row2 = QHBoxLayout() - for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn, - self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service): - row2.addWidget(w) - row2.addStretch(1) - - self.view = QPlainTextEdit(readOnly=True) - self.view.setMaximumBlockCount(MAX_LINES) - self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas") - mono.setStyleHint(QFont.StyleHint.Monospace) - mono.setPointSize(10) - self.view.setFont(mono) - self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }") - - self.diag = DiagnosticsPanel() - self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload) - self.side_tabs = QTabWidget() - self.side_tabs.addTab(self.diag, "Diagnosis") - self.side_tabs.addTab(self.guide, "Guided troubleshooting") - split = QSplitter(Qt.Orientation.Horizontal) - split.addWidget(self.view) - split.addWidget(self.side_tabs) - split.setStretchFactor(0, 3) - split.setStretchFactor(1, 2) - split.setSizes([700, 480]) - - self.status = QLabel() - layout = QVBoxLayout(self) - layout.addLayout(row1) - layout.addLayout(row2) - layout.addWidget(split, 1) - layout.addWidget(self.status) - - # --- wiring - self.refresh_btn.clicked.connect(self.refresh_ports) - self.all_ports.toggled.connect(self.refresh_ports) - self.connect_btn.clicked.connect(self._toggle_connect) - self.reset_btn.clicked.connect(self._manager.reset_board) - self.clear_btn.clicked.connect(self.view.clear) - self.save_btn.clicked.connect(self._save_view) - self.folder_btn.clicked.connect(self._open_folder) - self.analyze_btn.clicked.connect(self._analyze_file) - self.copy_btn.clicked.connect(self._copy_report) - self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False)) - self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec()) - - # Report refresh is throttled; lines can arrive at >100/s. - self._diag_timer = QTimer(self, interval=400) - self._diag_timer.timeout.connect(self._refresh_diag) - self._diag_timer.start() - - # Auto-detect a box being plugged in while idle. - self._poll = QTimer(self, interval=2000) - self._poll.timeout.connect(self._auto_refresh) - self._poll.start() - - self.refresh_ports() - self._on_state(State.DISCONNECTED, "") - - # ---- ports ------------------------------------------------------- - def refresh_ports(self) -> None: - current = self.port_combo.currentData() - self._ports = list_serial_ports(include_all=self.all_ports.isChecked()) - self.port_combo.clear() - for p in self._ports: - self.port_combo.addItem(p.label, p.device) - if not self._ports: - self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) - idx = self.port_combo.findData(current) - self.port_combo.setCurrentIndex(max(idx, 0)) - - def _auto_refresh(self) -> None: - if self._manager.is_running: - return - devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())] - if devices != [p.device for p in self._ports]: - self.refresh_ports() - - def _selected_port(self) -> PortInfo | None: - dev = self.port_combo.currentData() - return next((p for p in self._ports if p.device == dev), None) - - # ---- connection -------------------------------------------------- - def _toggle_connect(self) -> None: - if self._manager.is_running: - # Session file is closed in _on_state(DISCONNECTED), after the - # reader thread's final queued lines have been written. - self._manager.disconnect() - return - port = self._selected_port() - if port is None: - return - self._connect(port) - - def _connect(self, port: PortInfo) -> None: - self._counts = {"E": 0, "W": 0, "boots": 0} - self._engine.reset() - self._runner.cancel() - self._diag_dirty = True - self._session.start(port.mac or port.device) - self._active_port = port - self._manager.connect(port) - - def matter_payload(self) -> SetupPayload: - """Pairing payload from this box's log (VID/PID/discriminator), defaults otherwise.""" - return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else []) - - # ---- service system ------------------------------------------------- - def _service_base(self) -> str: - return str(self._settings.value("service/base", DEFAULT_BASE)) - - def _open_service_report(self, auto: bool) -> None: - mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "") - if len(mac) != 12: - if not auto: - QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. " - "Connect to the box and press Reset & capture first.") - return - url = report_url(mac, self._engine.report(), self._service_base()) - QDesktopServices.openUrl(QUrl(url)) - # Log that it happened, not the report itself (it is already in the session log). - self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there") - - # ---- hand-off with the flash tab ------------------------------------ - @property - def engine(self) -> DiagnosticsEngine: - return self._engine - - def release_port(self) -> None: - """Stop monitoring so esptool can open the port exclusively.""" - if self._manager.is_running: - self._no_auto_service = True # a flash hand-off is not the end of a session - self._manager.disconnect() # joins the reader thread - QCoreApplication.processEvents() # deliver its queued final lines/state now - self._no_auto_service = False - - def connect_port(self, port: PortInfo, capture: bool = True) -> None: - """Monitor `port`; with capture, restart the box once connected to record its boot.""" - self.release_port() - idx = self.port_combo.findData(port.device) - if idx < 0: - self.port_combo.addItem(port.label, port.device) - self._ports.append(port) - idx = self.port_combo.count() - 1 - self.port_combo.setCurrentIndex(idx) - self._reset_on_connect = capture - self._connect(port) - - def _on_state(self, state: State, detail: str) -> None: - running = state != State.DISCONNECTED - if not running and self._manager.is_running: - return # stale event from a session that was already replaced - if state == State.CONNECTED and getattr(self, "_reset_on_connect", False): - self._reset_on_connect = False - QTimer.singleShot(300, self._manager.reset_board) - if not running and self._session.path is not None: - self._session.write_report(self._engine.report().to_text()) - if (self.auto_service.isChecked() and self._engine.lines - and not getattr(self, "_no_auto_service", False)): - self._open_service_report(auto=True) - self._session.stop() - self.connect_btn.setText("Disconnect" if running else "Connect") - self.reset_btn.setEnabled(state == State.CONNECTED) - self.port_combo.setEnabled(not running) - self.all_ports.setEnabled(not running) - self.refresh_btn.setEnabled(not running) - # Offline analysis would mix with the live session; disconnect first. - self.analyze_btn.setEnabled(not running) - self._state_text = f"{state.value}" + (f" ({detail})" if detail else "") - self._update_status() - - def _update_status(self) -> None: - sess = f" | Session log: {self._session.path}" if self._session.path else "" - c = self._counts - self.status.setText( - f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} " - f"errors: {c['E']} warnings: {c['W']}{sess}" - ) - - # ---- lines ------------------------------------------------------- - def _on_line(self, line: LogLine) -> None: - self._session.write(line) - self._engine.feed(line) - self._runner.feed(line) - self._diag_dirty = True - if line.kind == LineKind.BOOT: - self._counts["boots"] += 1 - if line.level in ("E", "W"): - self._counts[line.level] += 1 - self._update_status() - - allowed = LEVEL_FILTERS[self.filter_combo.currentText()] - if allowed is not None and line.kind != LineKind.HOST and not ( - line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET) - ): - return - self._append(line) - - def _append(self, line: LogLine) -> None: - sb = self.view.verticalScrollBar() - at_bottom = sb.value() >= sb.maximum() - 4 - fmt = QTextCharFormat() - fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4"))) - if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST): - fmt.setFontWeight(QFont.Weight.Bold) - cur = QTextCursor(self.view.document()) - cur.movePosition(QTextCursor.MoveOperation.End) - if not self.view.document().isEmpty(): - cur.insertBlock() - cur.insertText(line.text, fmt) - if at_bottom: - sb.setValue(sb.maximum()) - - # ---- diagnostics ------------------------------------------------- - def _refresh_diag(self) -> None: - if self._diag_dirty: - self._diag_dirty = False - self.diag.show_report(self._engine.report()) - self.guide.refresh() # countdowns and timeouts need a steady tick - - # ---- guided troubleshooting hooks ------------------------------------ - def _guide_action(self, action: str) -> bool: - if action == "reset" and self._manager.state == State.CONNECTED: - self._manager.reset_board() - return True - return False - - def _guide_log(self, text: str) -> None: - # Technician answers go into the session log and the log view. - line = host_event(text) - self._session.write(line) - self._append(line) - self._diag_dirty = True - - def _copy_report(self) -> None: - QGuiApplication.clipboard().setText(self._engine.report().to_text()) - - def _analyze_file(self) -> None: - path, _ = QFileDialog.getOpenFileName( - self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)") - if not path: - return - lines = read_log_file(Path(path)) - self.view.clear() - self._engine.reset() - self._runner.cancel() - self._counts = {"E": 0, "W": 0, "boots": 0} - for line in lines: - self._engine.feed(line) - if line.kind == LineKind.BOOT: - self._counts["boots"] += 1 - if line.level in ("E", "W"): - self._counts[line.level] += 1 - self._append(line) - self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)" - self._update_status() - self._diag_dirty = True - self._refresh_diag() - - # ---- misc -------------------------------------------------------- - def _save_view(self) -> None: - path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)") - if path: - with open(path, "w", encoding="utf-8") as fh: - fh.write(self.view.toPlainText()) - - def _open_folder(self) -> None: - self._session.root.mkdir(parents=True, exist_ok=True) - QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root))) - - def shutdown(self) -> None: - self._manager.disconnect() - self._session.stop() - - -class MainWindow(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}") - self.resize(1200, 800) - tabs = QTabWidget() - guide = QTextBrowser(openExternalLinks=True) - guide.setHtml(cheatsheet_html()) - self.serial_tab = SerialTab() - self.flash_tab = FlashTab(self.serial_tab) - tabs.addTab(guide, "Quick start") - tabs.addTab(self.serial_tab, "Serial monitor") - tabs.addTab(self.flash_tab, "Flash firmware") - self.sd_tab = SdTab(self.serial_tab) - tabs.addTab(self.sd_tab, "SD card") - self.setCentralWidget(tabs) - settings_menu = self.menuBar().addMenu("Settings") - settings_menu.addAction("Service system address...", self._edit_service_base) - - def _edit_service_base(self) -> None: - st = self.serial_tab - text, ok = QInputDialog.getText( - self, "Service system address", - "Address of the KOTA service system (the box MAC is added at the end):", - text=st._service_base()) - if ok and text.strip().startswith(("https://", "http://")): - st._settings.setValue("service/base", text.strip()) - elif ok: - QMessageBox.warning(self, "Service system address", "The address must start with https://") - - def closeEvent(self, event): # noqa: N802 (Qt naming) - self.serial_tab.shutdown() - super().closeEvent(event) +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QFileDialog, + QHBoxLayout, + QInputDialog, + QLabel, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QPushButton, + QSplitter, + QTabWidget, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +from .. import __version__ +from ..core.logfile import read_log_file +from ..core.logline import LineKind, LogLine, host_event +from ..core.session import SessionLog +from ..diag.engine import DiagnosticsEngine +from ..diag.guides import GuideRunner +from ..serialio.manager import SerialManager, State +from ..serialio.ports import PortInfo, list_serial_ports +from ..matter.payload import SetupPayload, payload_from_log +from ..service.report import DEFAULT_BASE, normalize_mac, report_url +from .cheatsheet import cheatsheet_html +from .diag_panel import DiagnosticsPanel +from .flash_tab import FlashTab +from .matter_qr import MatterQrDialog +from .guide_panel import GuidePanel +from .sd_tab import SdTab + +MAX_LINES = 20000 +# "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 = { + "E": "#ff6b6b", + "W": "#f5c451", + "I": "#d4d4d4", + LineKind.BOOT: "#6fc3ff", + LineKind.RESET: "#6fc3ff", + LineKind.PANIC: "#ff4dd2", + LineKind.HOST: "#7ee07e", + LineKind.KOTA: "#5fd7e8", + LineKind.RAW: "#a8a8a8", +} + + +class _Bridge(QObject): + """Marshals SerialManager callbacks (reader thread) onto the UI thread.""" + + line = Signal(object) + state = Signal(object, str) + + +class SerialTab(QWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._bridge = _Bridge() + self._bridge.line.connect(self._on_line) + self._bridge.state.connect(self._on_state) + self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit) + self._session = SessionLog() + self._ports: list[PortInfo] = [] + self._counts = {"E": 0, "W": 0, "boots": 0} + self._engine = DiagnosticsEngine() + self._diag_dirty = True + self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log) + + # --- controls + self.port_combo = QComboBox(minimumWidth=320) + self.all_ports = QCheckBox("Show all ports") + self.refresh_btn = QPushButton("Refresh") + self.connect_btn = QPushButton("Connect") + self.reset_btn = QPushButton("Reset && capture") + self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line") + self.filter_combo = QComboBox() + self.filter_combo.addItems(LEVEL_FILTERS) + self.clear_btn = QPushButton("Clear view") + self.save_btn = QPushButton("Save view as...") + self.folder_btn = QPushButton("Open logs folder") + self.analyze_btn = QPushButton("Analyze log file...") + self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it") + self.copy_btn = QPushButton("Copy report") + self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)") + self.service_btn = QPushButton("Service report...") + self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis " + "filled in; check it and press Save there") + self._settings = QSettings("KOTA", "TechnicianTool") + self.auto_service = QCheckBox("Open service report on disconnect") + self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool)) + self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on)) + self._active_port: PortInfo | None = None + + row1 = QHBoxLayout() + for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn, + self.connect_btn, self.reset_btn): + row1.addWidget(w) + row1.addStretch(1) + row2 = QHBoxLayout() + for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn, + self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service): + row2.addWidget(w) + row2.addStretch(1) + + self.view = QPlainTextEdit(readOnly=True) + self.view.setMaximumBlockCount(MAX_LINES) + self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas") + mono.setStyleHint(QFont.StyleHint.Monospace) + mono.setPointSize(10) + self.view.setFont(mono) + self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }") + + self.diag = DiagnosticsPanel() + self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload) + self.side_tabs = QTabWidget() + self.side_tabs.addTab(self.diag, "Diagnosis") + self.side_tabs.addTab(self.guide, "Guided troubleshooting") + split = QSplitter(Qt.Orientation.Horizontal) + split.addWidget(self.view) + split.addWidget(self.side_tabs) + split.setStretchFactor(0, 3) + split.setStretchFactor(1, 2) + split.setSizes([700, 480]) + + self.status = QLabel() + layout = QVBoxLayout(self) + layout.addLayout(row1) + layout.addLayout(row2) + layout.addWidget(split, 1) + layout.addWidget(self.status) + + # --- wiring + self.refresh_btn.clicked.connect(self.refresh_ports) + self.all_ports.toggled.connect(self.refresh_ports) + self.connect_btn.clicked.connect(self._toggle_connect) + self.reset_btn.clicked.connect(self._manager.reset_board) + self.clear_btn.clicked.connect(self.view.clear) + self.save_btn.clicked.connect(self._save_view) + self.folder_btn.clicked.connect(self._open_folder) + self.analyze_btn.clicked.connect(self._analyze_file) + self.copy_btn.clicked.connect(self._copy_report) + self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False)) + self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec()) + + # Report refresh is throttled; lines can arrive at >100/s. + self._diag_timer = QTimer(self, interval=400) + self._diag_timer.timeout.connect(self._refresh_diag) + self._diag_timer.start() + + # Auto-detect a box being plugged in while idle. + self._poll = QTimer(self, interval=2000) + self._poll.timeout.connect(self._auto_refresh) + self._poll.start() + + self.refresh_ports() + self._on_state(State.DISCONNECTED, "") + + # ---- ports ------------------------------------------------------- + def refresh_ports(self) -> None: + current = self.port_combo.currentData() + self._ports = list_serial_ports(include_all=self.all_ports.isChecked()) + self.port_combo.clear() + for p in self._ports: + self.port_combo.addItem(p.label, p.device) + if not self._ports: + self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None) + idx = self.port_combo.findData(current) + self.port_combo.setCurrentIndex(max(idx, 0)) + + def _auto_refresh(self) -> None: + if self._manager.is_running: + return + devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())] + if devices != [p.device for p in self._ports]: + self.refresh_ports() + + def _selected_port(self) -> PortInfo | None: + dev = self.port_combo.currentData() + return next((p for p in self._ports if p.device == dev), None) + + # ---- connection -------------------------------------------------- + def _toggle_connect(self) -> None: + if self._manager.is_running: + # Session file is closed in _on_state(DISCONNECTED), after the + # reader thread's final queued lines have been written. + self._manager.disconnect() + return + port = self._selected_port() + if port is None: + return + self._connect(port) + + def _connect(self, port: PortInfo) -> None: + self._counts = {"E": 0, "W": 0, "boots": 0} + self._engine.reset() + self._runner.cancel() + self._diag_dirty = True + self._session.start(port.mac or port.device) + self._active_port = port + self._manager.connect(port) + + def matter_payload(self) -> SetupPayload: + """Pairing payload reported by this box (KOTA: matter), from its log, or the defaults.""" + return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [], + self._engine.matter_qr, self._engine.matter_manual) + + # ---- service system ------------------------------------------------- + def _service_base(self) -> str: + return str(self._settings.value("service/base", DEFAULT_BASE)) + + def _open_service_report(self, auto: bool) -> None: + mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "") + if len(mac) != 12: + if not auto: + QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. " + "Connect to the box and press Reset & capture first.") + return + url = report_url(mac, self._engine.report(), self._service_base()) + QDesktopServices.openUrl(QUrl(url)) + # Log that it happened, not the report itself (it is already in the session log). + self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there") + + # ---- hand-off with the flash tab ------------------------------------ + @property + def engine(self) -> DiagnosticsEngine: + return self._engine + + def release_port(self) -> None: + """Stop monitoring so esptool can open the port exclusively.""" + if self._manager.is_running: + self._no_auto_service = True # a flash hand-off is not the end of a session + self._manager.disconnect() # joins the reader thread + QCoreApplication.processEvents() # deliver its queued final lines/state now + self._no_auto_service = False + + def connect_port(self, port: PortInfo, capture: bool = True) -> None: + """Monitor `port`; with capture, restart the box once connected to record its boot.""" + self.release_port() + idx = self.port_combo.findData(port.device) + if idx < 0: + self.port_combo.addItem(port.label, port.device) + self._ports.append(port) + idx = self.port_combo.count() - 1 + self.port_combo.setCurrentIndex(idx) + self._reset_on_connect = capture + self._connect(port) + + def _on_state(self, state: State, detail: str) -> None: + running = state != State.DISCONNECTED + if not running and self._manager.is_running: + return # stale event from a session that was already replaced + if state == State.CONNECTED and getattr(self, "_reset_on_connect", False): + self._reset_on_connect = False + QTimer.singleShot(300, self._manager.reset_board) + if not running and self._session.path is not None: + self._session.write_report(self._engine.report().to_text()) + if (self.auto_service.isChecked() and self._engine.lines + and not getattr(self, "_no_auto_service", False)): + self._open_service_report(auto=True) + self._session.stop() + self.connect_btn.setText("Disconnect" if running else "Connect") + self.reset_btn.setEnabled(state == State.CONNECTED) + self.port_combo.setEnabled(not running) + self.all_ports.setEnabled(not running) + self.refresh_btn.setEnabled(not running) + # Offline analysis would mix with the live session; disconnect first. + self.analyze_btn.setEnabled(not running) + self._state_text = f"{state.value}" + (f" ({detail})" if detail else "") + self._update_status() + + def _update_status(self) -> None: + sess = f" | Session log: {self._session.path}" if self._session.path else "" + c = self._counts + self.status.setText( + f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} " + f"errors: {c['E']} warnings: {c['W']}{sess}" + ) + + # ---- lines ------------------------------------------------------- + def _on_line(self, line: LogLine) -> None: + self._session.write(line) + self._engine.feed(line) + self._runner.feed(line) + self._diag_dirty = True + if line.kind == LineKind.BOOT: + self._counts["boots"] += 1 + if line.level in ("E", "W"): + self._counts[line.level] += 1 + self._update_status() + + if self._visible(line): + self._append(line) + + def _visible(self, line: LogLine) -> bool: + allowed = LEVEL_FILTERS[self.filter_combo.currentText()] + if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET): + return True + if allowed == "KOTA": + return line.kind == LineKind.KOTA or line.level == "E" + return line.level in allowed + + def _append(self, line: LogLine) -> None: + sb = self.view.verticalScrollBar() + at_bottom = sb.value() >= sb.maximum() - 4 + fmt = QTextCharFormat() + colour = COLORS.get(line.kind) if line.kind == LineKind.KOTA and line.level == "I" else None + fmt.setForeground(QColor(colour or COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4"))) + if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST) or line.record in ("boot", "status"): + fmt.setFontWeight(QFont.Weight.Bold) + cur = QTextCursor(self.view.document()) + cur.movePosition(QTextCursor.MoveOperation.End) + if not self.view.document().isEmpty(): + cur.insertBlock() + cur.insertText(line.text, fmt) + if at_bottom: + sb.setValue(sb.maximum()) + + # ---- diagnostics ------------------------------------------------- + def _refresh_diag(self) -> None: + if self._diag_dirty: + self._diag_dirty = False + self.diag.show_report(self._engine.report()) + self.guide.refresh() # countdowns and timeouts need a steady tick + + # ---- guided troubleshooting hooks ------------------------------------ + def _guide_action(self, action: str) -> bool: + if action == "reset" and self._manager.state == State.CONNECTED: + self._manager.reset_board() + return True + return False + + def _guide_log(self, text: str) -> None: + # Technician answers go into the session log and the log view. + line = host_event(text) + self._session.write(line) + self._append(line) + self._diag_dirty = True + + def _copy_report(self) -> None: + QGuiApplication.clipboard().setText(self._engine.report().to_text()) + + def _analyze_file(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)") + if not path: + return + lines = read_log_file(Path(path)) + self.view.clear() + self._engine.reset() + self._runner.cancel() + self._counts = {"E": 0, "W": 0, "boots": 0} + for line in lines: + self._engine.feed(line) + if line.kind == LineKind.BOOT: + self._counts["boots"] += 1 + if line.level in ("E", "W"): + self._counts[line.level] += 1 + self._append(line) + self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)" + self._update_status() + self._diag_dirty = True + self._refresh_diag() + + # ---- misc -------------------------------------------------------- + def _save_view(self) -> None: + path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)") + if path: + with open(path, "w", encoding="utf-8") as fh: + fh.write(self.view.toPlainText()) + + def _open_folder(self) -> None: + self._session.root.mkdir(parents=True, exist_ok=True) + QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root))) + + def shutdown(self) -> None: + self._manager.disconnect() + self._session.stop() + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}") + self.resize(1200, 800) + tabs = QTabWidget() + guide = QTextBrowser(openExternalLinks=True) + guide.setHtml(cheatsheet_html()) + self.serial_tab = SerialTab() + self.flash_tab = FlashTab(self.serial_tab) + tabs.addTab(guide, "Quick start") + tabs.addTab(self.serial_tab, "Serial monitor") + tabs.addTab(self.flash_tab, "Flash firmware") + self.sd_tab = SdTab(self.serial_tab) + tabs.addTab(self.sd_tab, "SD card") + self.setCentralWidget(tabs) + settings_menu = self.menuBar().addMenu("Settings") + settings_menu.addAction("Service system address...", self._edit_service_base) + + def _edit_service_base(self) -> None: + st = self.serial_tab + text, ok = QInputDialog.getText( + self, "Service system address", + "Address of the KOTA service system (the box MAC is added at the end):", + text=st._service_base()) + if ok and text.strip().startswith(("https://", "http://")): + st._settings.setValue("service/base", text.strip()) + elif ok: + QMessageBox.warning(self, "Service system address", "The address must start with https://") + + def closeEvent(self, event): # noqa: N802 (Qt naming) + self.serial_tab.shutdown() + super().closeEvent(event) diff --git a/kota_tech/ui/matter_qr.py b/kota_tech/ui/matter_qr.py index c687f1d..18e2f2e 100644 --- a/kota_tech/ui/matter_qr.py +++ b/kota_tech/ui/matter_qr.py @@ -1,72 +1,76 @@ -"""Matter pairing QR code on screen, so the technician can scan it from the laptop.""" - -from __future__ import annotations - -import segno -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor, QImage, QPainter, QPixmap -from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget - -from ..matter.payload import SetupPayload - - -def qr_pixmap(text: str, size: int = 260) -> QPixmap: - """Black-on-white QR with the quiet zone phones need (4 modules).""" - qr = segno.make(text, error="m") - matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)] - n = len(matrix) - module = max(1, size // n) - img = QImage(n * module, n * module, QImage.Format.Format_RGB32) - img.fill(QColor("white")) - p = QPainter(img) - p.setPen(Qt.PenStyle.NoPen) - p.setBrush(QColor("black")) - for y, row in enumerate(matrix): - for x, dark in enumerate(row): - if dark: - p.drawRect(x * module, y * module, module, module) - p.end() - return QPixmap.fromImage(img) - - -class MatterQrWidget(QWidget): - def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None): - super().__init__(parent) - self.image = QLabel() - self.image.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.code = QLabel() - self.code.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - f = self.code.font() - f.setPointSize(f.pointSize() + 4) - f.setBold(True) - self.code.setFont(f) - self.note = QLabel(wordWrap=True) - self.note.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.note.setStyleSheet("color:#555") - lay = QVBoxLayout(self) - lay.addWidget(self.image) - lay.addWidget(self.code) - lay.addWidget(self.note) - self._size = size - self.set_payload(payload) - - def set_payload(self, payload: SetupPayload) -> None: - self.payload = payload - self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size)) - self.code.setText(f"Manual code: {payload.manual_code_pretty()}") - src = ("from this box's log" if payload.from_log else - "standard code of the v1.0 firmware - connect the box to confirm") - self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. " - f"Discriminator {payload.discriminator} ({src}). Same as the sticker on the box.") - - -class MatterQrDialog(QDialog): - def __init__(self, payload: SetupPayload, parent: QWidget | None = None): - super().__init__(parent) - self.setWindowTitle("Matter pairing code") - lay = QVBoxLayout(self) - lay.addWidget(MatterQrWidget(payload, 320)) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - buttons.rejected.connect(self.reject) - lay.addWidget(buttons) +"""Matter pairing QR code on screen, so the technician can scan it from the laptop.""" + +from __future__ import annotations + +import segno +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QImage, QPainter, QPixmap +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget + +from ..matter.payload import SetupPayload + + +def qr_pixmap(text: str, size: int = 260) -> QPixmap: + """Black-on-white QR with the quiet zone phones need (4 modules).""" + qr = segno.make(text, error="m") + matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)] + n = len(matrix) + module = max(1, size // n) + img = QImage(n * module, n * module, QImage.Format.Format_RGB32) + img.fill(QColor("white")) + p = QPainter(img) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor("black")) + for y, row in enumerate(matrix): + for x, dark in enumerate(row): + if dark: + p.drawRect(x * module, y * module, module, module) + p.end() + return QPixmap.fromImage(img) + + +class MatterQrWidget(QWidget): + def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None): + super().__init__(parent) + self.image = QLabel() + self.image.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.code = QLabel() + self.code.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + f = self.code.font() + f.setPointSize(f.pointSize() + 4) + f.setBold(True) + self.code.setFont(f) + self.note = QLabel(wordWrap=True) + self.note.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.note.setStyleSheet("color:#555") + lay = QVBoxLayout(self) + lay.addWidget(self.image) + lay.addWidget(self.code) + lay.addWidget(self.note) + self._size = size + self.set_payload(payload) + + def set_payload(self, payload: SetupPayload) -> None: + self.payload = payload + self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size)) + self.code.setText(f"Manual code: {payload.manual_code_pretty()}") + if payload.from_box: + src = "QR and manual code exactly as reported by the box (KOTA: matter line)" + else: + src = f"Discriminator {payload.discriminator} " + ( + "(from this box's log)" if payload.from_log else + "(standard code of the v1.0 firmware - connect the box to confirm)") + self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. " + f"{src}. Same as the sticker on the box.") + + +class MatterQrDialog(QDialog): + def __init__(self, payload: SetupPayload, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Matter pairing code") + lay = QVBoxLayout(self) + lay.addWidget(MatterQrWidget(payload, 320)) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + buttons.rejected.connect(self.reject) + lay.addWidget(buttons) diff --git a/pyproject.toml b/pyproject.toml index 8f0f00e..f8623e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kota-technician-tool" -version = "0.6.0" +version = "0.7.0" description = "KOTA sensorbox technician diagnostic tool (demo)" requires-python = ">=3.11,<3.14" dependencies = [ diff --git a/run_macos.command b/run_macos.command index 05d1c31..361a151 100755 --- a/run_macos.command +++ b/run_macos.command @@ -3,7 +3,7 @@ cd "$(dirname "$0")" || exit 1 # 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.7" export PATH="$HOME/.local/bin:$PATH" if ! command -v uv >/dev/null 2>&1; then diff --git a/run_windows.bat b/run_windows.bat index 3ec2a56..f000e51 100644 --- a/run_windows.bat +++ b/run_windows.bat @@ -3,7 +3,7 @@ setlocal cd /d "%~dp0" 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.7" set "PATH=%USERPROFILE%\.local\bin;%PATH%" where uv >nul 2>nul diff --git a/tests/fixtures/live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt b/tests/fixtures/live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt new file mode 100644 index 0000000..d1dd4d3 --- /dev/null +++ b/tests/fixtures/live_boot_A4CB8FC946F8_fw1.0.16_uncommissioned.txt @@ -0,0 +1,1064 @@ +--- connected to COM5 --- +W (7411) wifi:Haven't to connect to a suitable AP now! +--- reset issued by tool --- +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x15 (USB_UART_CHIP_RESET),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x4037f35e +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (25) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (28) boot: efuse block revision: v1.4 +I (32) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (43) boot: Enabling RNG early entropy source... +I (48) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (63) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (76) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (89) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (102) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (109) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c190020 size=66d30h (421168) map +I (194) esp_image: segment 1: paddr=000a6d58 vaddr=3fc9c700 size=05d00h ( 23808) load +I (199) esp_image: segment 2: paddr=000aca60 vaddr=40374000 size=035b8h ( 13752) load +I (203) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=1862d0h (1598160) map +I (487) esp_image: segment 4: paddr=002362f8 vaddr=403775b8 size=15054h ( 86100) load +I (506) esp_image: segment 5: paddr=0024b354 vaddr=5000002c size=00020h ( 32) load +I (516) boot: Loaded app from partition at offset 0x40000 +I (517) boot: Disabling RNG early entropy source... +I (527) octal_psram: vendor id : 0x0d (AP) +I (527) octal_psram: dev id : 0x02 (generation 3) +I (528) octal_psram: density : 0x03 (64 Mbit) +I (529) octal_psram: good-die : 0x01 (Pass) +I (534) octal_psram: Latency : 0x01 (Fixed) +I (538) octal_psram: VCC : 0x01 (3V) +I (542) octal_psram: SRF : 0x01 (Fast Refresh) +I (547) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (552) octal_psram: BurstLen : 0x01 (32 Byte) +I (556) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (562) octal_psram: DriveStrength: 0x00 (1/1) +I (566) esp_psram: Found 8MB PSRAM device +I (569) esp_psram: Speed: 40MHz +I (572) cpu_start: Multicore app +I (1306) esp_psram: SPI SRAM memory test OK +I (1314) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1315) cpu_start: Pro cpu start user code +I (1315) cpu_start: cpu freq: 160000000 Hz +I (1317) app_init: Application information: +I (1321) app_init: Project name: kota-sensorboard +I (1326) app_init: App version: 1.0.16 +I (1330) app_init: Compile time: Sep 28 2026 15:07:57 +I (1335) app_init: ELF file SHA256: 4f10989e2... +I (1339) app_init: ESP-IDF: v5.5.3-dirty +I (1344) efuse_init: Min chip rev: v0.0 +I (1347) efuse_init: Max chip rev: v0.99 +I (1352) efuse_init: Chip rev: v0.2 +I (1356) heap_init: Initializing. RAM available for dynamic allocation: +I (1362) heap_init: At 3FCB4AD0 len 00034C40 (211 KiB): RAM +I (1367) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1372) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1378) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1383) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1391) spi_flash: detected chip: generic +I (1393) spi_flash: flash io: dio +I (1399) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1403) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1418) main_task: Started on CPU0 +I (1421) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1429) main_task: Calling app_main() +E (1432) ID: [E-MAIN-04] Device MAC address: A4:CB:8F:C9:46:F8 +I (1458) KOTA: led color=green pattern=solid +I (1659) I2C: Creating I2C mutex +I (1676) KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66 0x76=BME688 +I (1677) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1679) SPI: Creating SPI mutex +I (1722) SD_CARD: SD card mounted successfully +I (1723) SD_CARD: /sdcard exists, SD card initialized successfully +I (1788) KOTA: sd type=SDHC size=29.3GB fs=FAT32 free=29.3GB env_keys=INFLUX_TOKEN,INFLUX_URL buffer_lines=764 +I (1792) WIFI_CREDENTIALS: SSID/PASSWORD not set in .env; Matter will be used for WiFi provisioning +I (1802) MAIN: Matter node created successfully +W (1803) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1810) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1818) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1827) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1836) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1845) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1854) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1862) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1871) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1880) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1888) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1897) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1906) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1915) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1924) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1932) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1941) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1950) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1960) pp: pp rom version: e7ae62f +I (1962) net80211: net80211 rom version: e7ae62f +I (1967) wifi:wifi driver task: 3fcc7d40, prio:23, stack:6656, core=0 +I (1985) wifi:wifi firmware version: 4df78f2 +I (1985) wifi:wifi certification version: v7.0 +I (1986) wifi:config NVS flash: enabled +I (1986) wifi:config nano formatting: disabled +I (1990) wifi:Init data frame dynamic rx buffer num: 32 +I (1995) wifi:Init static rx mgmt buffer num: 5 +I (1999) wifi:Init management short buffer num: 32 +I (2004) wifi:Init dynamic tx buffer num: 32 +I (2008) wifi:Init static tx FG buffer num: 2 +I (2012) wifi:Init static rx buffer size: 1600 +I (2016) wifi:Init static rx buffer num: 10 +I (2020) wifi:Init dynamic rx buffer num: 32 +I (2025) wifi_init: rx ba win: 6 +I (2027) wifi_init: accept mbox: 6 +I (2030) wifi_init: tcpip mbox: 32 +I (2033) wifi_init: udp mbox: 6 +I (2036) wifi_init: tcp mbox: 6 +I (2039) wifi_init: tcp tx win: 5760 +I (2042) wifi_init: tcp rx win: 5760 +I (2045) wifi_init: tcp mss: 1440 +I (2057) chip[DL]: NVS set: chip-counters/reboot-count = 15857 (0x3DF1) +I (2060) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (2063) BLE_INIT: BT controller compile version [1bb2f50] +I (2067) BLE_INIT: Using main XTAL as clock source +I (2072) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (2080) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (2085) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +I (2133) NimBLE: GAP procedure initiated: stop advertising. + +I (2135) CHIP[DL]: BLE host-controller synced +I (2635) chip[DL]: Starting ESP WiFi layer +I (2637) wifi:mode : sta (a4:cb:8f:c9:46:f8) +I (2638) wifi:enable tsf +W (2639) wifi:Haven't to connect to a suitable AP now! +I (2639) chip[DL]: +I (2639) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43Done driving station state, nothing else to do... + +W (2652) wifi:Haven't to connect to a suitable AP now! +I (2657) chip[DL]: Done driving station state, nothing else to do... +I (2657) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2 +I (2663) chip[SVR]: +Initializing subscription resumption storage... +I (2676) chip[SVR]: Server initializing... +I (2680) chip[TS]: Last Known Good Time: 2026-09-28T13:29:12 +I (2685) chip[DMG]: AccessControl: initializing +I (2688) chip[DMG]: Examples::AccessControlDelegate::Init +I (2693) chip[DMG]: AccessControl: setting +I (2697) chip[DMG]: DefaultAclStorage: initializing +I (2702) chip[DMG]: DefaultAclStorage: 0 entries loaded +I (2707) chip[SVR]: WARNING: mTestEventTriggerDelegate is null +I (2726) chip[ZCL]: Using ZAP configuration... +I (2726) esp_matter_cluster: Cluster plugin init common callback +I (2727) chip[DMG]: AccessControlCluster: initializing +I (2730) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported +I (2740) chip[DIS]: Updating services using commissioning mode 1 +I (2744) chip[DIS]: CHIP minimal mDNS started advertising. +I (2749) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0 +I (2759) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: A180A7E5D58D9F80. +I (2769) chip[DIS]: mDNS service published: _matterc._udp +I (2774) chip[IN]: CASE Server enabling CASE session setups +I (2780) chip[SVR]: Joining Multicast groups +I (2783) chip[SVR]: Server Listening... +I (2788) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 ********** +I (2798) data_model: Dynamic endpoint 0 added +E (2801) data_model: Cluster cannot be NULL. +I (2806) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 ********** +I (2816) data_model: Dynamic endpoint 1 added +E (2820) data_model: Cluster cannot be NULL. +I (2825) MAIN: ESP-Matter started successfully +I (2826) chip[DL]: WIFI_EVENT_STA_START +W (2832) wifi:Haven't to connect to a suitable AP now! +I (2837) chip[DL]: Done driving station state, nothing else to do... +I (2843) KOTA: boot fw=1.0.16 build=4f10989e hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET mode=matter commissioned=0 last_restart=none +I (2845) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable) +I (2862) NimBLE: GAP procedure initiated: advertise; +I (2866) NimBLE: disc_mode=2 +I (2869) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40 +I (2878) NimBLE: + +I (2881) chip[DL]: CHIPoBLE advertising started +I (2886) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332 +I (2892) MAIN: Commissioning window opened +W (2893) wifi:Haven't to connect to a suitable AP now! +I (2897) KOTA: matter fabrics=0 window=open qr=MT:Y.K9042C00KA0648G00 manual=34970112332 +I (2905) KOTA: led color=cyan pattern=breathe period=3000ms +I (2901) KOTA: health up=1s status=OK wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=0 heap=123883 psram=7.9M last_upload=never ota=unregistered +W (3394) wifi:Haven't to connect to a suitable AP now! +I (3894) KOTA: rtc running=0 time=2000-01-01T00:00:00 +W (4395) wifi:Haven't to connect to a suitable AP now! +W (5396) wifi:Haven't to connect to a suitable AP now! +W (6396) wifi:Haven't to connect to a suitable AP now! +W (7397) wifi:Haven't to connect to a suitable AP now! +W (8398) wifi:Haven't to connect to a suitable AP now! +W (9399) wifi:Haven't to connect to a suitable AP now! +W (10400) wifi:Haven't to connect to a suitable AP now! +W (11401) wifi:Haven't to connect to a suitable AP now! +W (12402) wifi:Haven't to connect to a suitable AP now! +W (13403) wifi:Haven't to connect to a suitable AP now! +I (13895) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +W (13962) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +I (13975) BME688: Delaying for 10000 microseconds +W (14404) wifi:Haven't to connect to a suitable AP now! +I (14591) KOTA: mic enabled=1 (GPIO3=0, expected 0) +I (14593) Flicker: Flicker sensor initialized on pin 14 +I (14594) temperature_sensor: Range [20°C ~ 100°C], error < 2°C +I (14597) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data +W (14601) wifi: +I (14597) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data +Haven't to connect to a suitable AP now! +I (14598) SEN66: Creating sensor mutex + +I (14605) INFLUX_BUFFER: Flushing chunk starting at line 10: +W (14611) AS7341: Reading spectral data from AS7341 +I (14616) SEN66: Waking up sensor by setting power GPIO to enabled level... +I (14617) DeviceStatus: Active tasks: 26 +I (14621) INFLUX: Writing to InfluxDB +I (14641) DeviceStatus: Task list: +main R 1 1132 4 +gather_sensor_t X 1 1480 24 +IDLE1 R 0 700 6 +IDLE0 R 0 692 5 +gather_sensor_t B 1 2552 22 +reset_task B 0 3344 9 +ds1307_update B 1 768 17 +tiT B 18 2420 11 +gather_task B 1 1984 19 +gather_task B 1 14292 20 +gather_sensor_t B 1 1364 25 +gather_sensor_t B 1 1948 23 +ota_task B 1 7504 18 +CHIP B 1 3636 13 +health B 1 928 16 +Tmr Svc B 1 1364 7 +led_task B 5 964 8 +sys_evt B 20 2676 10 +ipc0 S 24 372 1 +ipc1 S 24 524 2 +esp_timer S 22 3332 3 +gather_sensor_t B 1 2544 21 +gather_sensor_t B 1 2552 26 +wifi B 23 4324 12 +btController B 23 1960 14 +nimble_host B 21 1476 15 + +W (14710) DeviceStatus: RTC is uninitialized, using current time as timestamp +W (14973) AS7341: Reading basic counts data from AS7341 +W (14989) AS7341: Reading flicker detection state from AS7341 +W (15206) as7341: FD Status Register: 0x20 (0b00100000) +I (15211) AS7341: Updating Matter attributes with AS7341 data: 14946 +I (15211) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms +I (15212) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 ********** +I (15217) Flicker: Starting to collect samples in gather task +I (15228) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 14946 ********** +E (15245) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +E (15254) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +W (15264) AS7341: Formatting AS7341 data in line protocol +AS7341 line protocol: +as7341_adc,device=A4CB8FC946F8 f1=1,f2=2,f3=2,f4=3,f5=3,f6=3,f7=6,f8=4,clear=14,nir=2 946684801000000000 +as7341_basic_counts,device=A4CB8FC946F8 f1=2.23,f2=4.46,f3=4.46,f4=6.69,f5=6.69,f6=6.69,f7=13.39,f8=8.92,clear=31.23,nir=4.46 946684801000000000 +as7341_flicker,device=A4CB8FC946F8 state="unknown" 946684801000000000 +W (15404) wifi:Haven't to connect to a suitable AP now! +W (15717) DeviceStatus: RTC is uninitialized, using current time as timestamp +I (16256) Flicker: Finished collecting samples, notifying main task +I (16257) Flicker: Collected 1024 samples for dominant frequency analysis +I (16264) SENSOR_GATHERER: Sensor should now be asleep +I (16276) BME688: Delaying for 199517 microseconds +I (16276) SENSOR_GATHERER: Sensor should now be asleep +W (16405) wifi:Haven't to connect to a suitable AP now! +I (16714) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null ********** +I (16715) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1018 ********** +E (16726) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +I (16717) SENSOR_GATHERER: Sensor should now be asleep +E (16735) MAIN: [E-MAIN-03] Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +W (16750) BME688: RTC is uninitialized, using current time as timestamp +W (17406) wifi:Haven't to connect to a suitable AP now! +W (17756) BME688: RTC is uninitialized, using current time as timestamp +W (18407) wifi:Haven't to connect to a suitable AP now! +W (18756) BME688: RTC is uninitialized, using current time as timestamp +W (19408) wifi:Haven't to connect to a suitable AP now! +I (19633) SEN66: Sent start measurement command, waiting for sensor to be ready... +W (19756) BME688: RTC is uninitialized, using current time as timestamp +W (20409) wifi:Haven't to connect to a suitable AP now! +W (20756) BME688: RTC is uninitialized, using current time as timestamp +W (21410) wifi:Haven't to connect to a suitable AP now! +E (21642) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (21642) esp-tls: Failed to open new connection +E (21645) transport_base: Failed to open a new connection +E (21650) HTTP_CLIENT: Connection failed, sock < 0 +I (21655) INFLUX: HTTP request performed, status code: 0 +E (21659) INFLUX: [E-INF-03] Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (21667) INFLUX_BUFFER: [E-INF-17] Failed to write chunk to Influx: ESP_FAIL +W (21756) BME688: RTC is uninitialized, using current time as timestamp +W (22411) wifi:Haven't to connect to a suitable AP now! +I (22602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (22756) BME688: RTC is uninitialized, using current time as timestamp +I (23102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (23412) wifi:Haven't to connect to a suitable AP now! +I (23602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (23674) INFLUX: Writing to InfluxDB +W (23756) BME688: RTC is uninitialized, using current time as timestamp +I (24102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (24413) wifi:Haven't to connect to a suitable AP now! +I (24596) OTA_CLIENT: Device is not registered, attempting to register... +I (24598) OTA_CLIENT: HTTP client handle: 0x3fcf0fe4 +I (24602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (24738) MICROPHONE: Read 600000 bytes in 9940 ms +I (24738) MICROPHONE: Read 600000 bytes of audio data +W (24756) BME688: RTC is uninitialized, using current time as timestamp +I (25102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (25414) wifi:Haven't to connect to a suitable AP now! +I (25602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (25756) BME688: RTC is uninitialized, using current time as timestamp +I (26102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (26415) wifi:Haven't to connect to a suitable AP now! +I (26602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (26756) BME688: RTC is uninitialized, using current time as timestamp +I (27102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (27416) wifi:Haven't to connect to a suitable AP now! +I (27602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (27756) BME688: RTC is uninitialized, using current time as timestamp +I (28102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (28187) MICROPHONE: Raw block stats: mean=-81.8 min=-41376 max=37946 +I (28188) MICROPHONE: Sum SQ: 0.032256, RMS pressure: 0.000402, Normalized: 0.000402, dB peak: -67.92 +W (28192) SEN66: RTC is uninitialized, using current time as timestamp +W (28417) wifi:Haven't to connect to a suitable AP now! +I (28602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (28756) BME688: RTC is uninitialized, using current time as timestamp +I (29102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (29198) SEN66: RTC is uninitialized, using current time as timestamp +W (29418) wifi:Haven't to connect to a suitable AP now! +I (29602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (29756) BME688: RTC is uninitialized, using current time as timestamp +I (30102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (30198) SEN66: RTC is uninitialized, using current time as timestamp +W (30419) wifi:Haven't to connect to a suitable AP now! +I (30602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +E (30675) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (30675) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (30676) esp-tls: Failed to open new connection +E (30683) esp-tls: Failed to open new connection +E (30687) transport_base: Failed to open a new connection +E (30691) transport_base: Failed to open a new connection +E (30696) HTTP_CLIENT: Connection failed, sock < 0 +E (30701) HTTP_CLIENT: Connection failed, sock < 0 +E (30706) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:228 +I (30710) INFLUX: HTTP request performed, status code: 0 +E (30719) OTA_CLIENT: [E-OTA-12] Device registration failed: ESP_ERR_HTTP_CONNECT +E (30723) INFLUX: [E-INF-03] Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (30738) INFLUX_BUFFER: [E-INF-17] Failed to write chunk to Influx: ESP_FAIL +W (30756) BME688: RTC is uninitialized, using current time as timestamp +I (31102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (31198) SEN66: RTC is uninitialized, using current time as timestamp +W (31420) wifi:Haven't to connect to a suitable AP now! +I (31602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (31756) BME688: RTC is uninitialized, using current time as timestamp +I (32102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (32198) SEN66: RTC is uninitialized, using current time as timestamp +W (32421) wifi:Haven't to connect to a suitable AP now! +I (32602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (32741) chip[DL]: bleAdv Timeout : Start slow advertisement +I (32743) chip[DL]: Configuring CHIPoBLE advertising (interval 500 ms, connectable) +I (32744) chip[DL]: Device already advertising, stop active advertisement and restart +I (32745) INFLUX: Writing to InfluxDB +I (32751) NimBLE: GAP procedure initiated: stop advertising. + +W (32756) BME688: RTC is uninitialized, using current time as timestamp +I (32760) NimBLE: GAP procedure initiated: advertise; +I (32770) NimBLE: disc_mode=2 +I (32773) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=800 adv_itvl_max=800 +I (32783) NimBLE: + +I (33102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (33198) SEN66: RTC is uninitialized, using current time as timestamp +W (33425) wifi:Haven't to connect to a suitable AP now! +I (33602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (33785) BME688: RTC is uninitialized, using current time as timestamp +I (34102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (34198) SEN66: RTC is uninitialized, using current time as timestamp +W (34426) wifi:Haven't to connect to a suitable AP now! +I (34602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (34785) BME688: RTC is uninitialized, using current time as timestamp +I (35102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (35198) SEN66: RTC is uninitialized, using current time as timestamp +W (35427) wifi:Haven't to connect to a suitable AP now! +I (35602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (35785) BME688: RTC is uninitialized, using current time as timestamp +I (36102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (36198) SEN66: RTC is uninitialized, using current time as timestamp +W (36428) wifi:Haven't to connect to a suitable AP now! +I (36602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (36785) BME688: RTC is uninitialized, using current time as timestamp +I (37102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (37198) SEN66: RTC is uninitialized, using current time as timestamp +W (37429) wifi:Haven't to connect to a suitable AP now! +I (37602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (37785) BME688: RTC is uninitialized, using current time as timestamp +I (38102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (38198) SEN66: RTC is uninitialized, using current time as timestamp +W (38430) wifi:Haven't to connect to a suitable AP now! +I (38602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (38785) BME688: RTC is uninitialized, using current time as timestamp +I (39102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (39198) SEN66: RTC is uninitialized, using current time as timestamp +W (39431) wifi:Haven't to connect to a suitable AP now! +I (39602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +E (39754) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (39754) esp-tls: Failed to open new connection +E (39757) transport_base: Failed to open a new connection +E (39762) HTTP_CLIENT: Connection failed, sock < 0 +I (39766) INFLUX: HTTP request performed, status code: 0 +E (39771) INFLUX: [E-INF-03] Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (39779) INFLUX_BUFFER: [E-INF-17] Failed to write chunk to Influx: ESP_FAIL +W (39785) BME688: RTC is uninitialized, using current time as timestamp +I (40102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (40198) SEN66: RTC is uninitialized, using current time as timestamp +W (40432) wifi:Haven't to connect to a suitable AP now! +I (40602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (40731) OTA_CLIENT: Device is not registered, attempting to register... +I (40733) OTA_CLIENT: HTTP client handle: 0x3fcf02e4 +W (40792) BME688: RTC is uninitialized, using current time as timestamp +I (41102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (41198) SEN66: RTC is uninitialized, using current time as timestamp +W (41433) wifi:Haven't to connect to a suitable AP now! +I (41602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (41786) INFLUX: Writing to InfluxDB +W (41792) BME688: RTC is uninitialized, using current time as timestamp +I (42102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (42198) SEN66: RTC is uninitialized, using current time as timestamp +W (42434) wifi:Haven't to connect to a suitable AP now! +I (42602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (42792) BME688: RTC is uninitialized, using current time as timestamp +I (43102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (43198) SEN66: RTC is uninitialized, using current time as timestamp +W (43435) wifi:Haven't to connect to a suitable AP now! +I (43602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (43792) BME688: RTC is uninitialized, using current time as timestamp +I (44102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (44198) SEN66: RTC is uninitialized, using current time as timestamp +W (44436) wifi:Haven't to connect to a suitable AP now! +I (44602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (44792) BME688: RTC is uninitialized, using current time as timestamp +I (45102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (45198) SEN66: RTC is uninitialized, using current time as timestamp +W (45437) wifi:Haven't to connect to a suitable AP now! +I (45602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (45792) BME688: RTC is uninitialized, using current time as timestamp +I (46102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (46198) SEN66: RTC is uninitialized, using current time as timestamp +W (46438) wifi:Haven't to connect to a suitable AP now! +I (46602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (46792) BME688: RTC is uninitialized, using current time as timestamp +I (47102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (47198) SEN66: RTC is uninitialized, using current time as timestamp +W (47439) wifi:Haven't to connect to a suitable AP now! +I (47602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +E (47734) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (47735) esp-tls: Failed to open new connection +E (47736) transport_base: Failed to open a new connection +E (47742) HTTP_CLIENT: Connection failed, sock < 0 +E (47746) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:228 +E (47754) OTA_CLIENT: [E-OTA-12] Device registration failed: ESP_ERR_HTTP_CONNECT +W (47792) BME688: RTC is uninitialized, using current time as timestamp +I (48102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (48198) SEN66: RTC is uninitialized, using current time as timestamp +W (48440) wifi:Haven't to connect to a suitable AP now! +I (48602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +E (48734) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (48735) esp-tls: Failed to open new connection +E (48737) transport_base: Failed to open a new connection +E (48742) HTTP_CLIENT: Connection failed, sock < 0 +I (48746) INFLUX: HTTP request performed, status code: 0 +E (48751) INFLUX: [E-INF-03] Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (48759) INFLUX_BUFFER: [E-INF-17] Failed to write chunk to Influx: ESP_FAIL +W (48792) BME688: RTC is uninitialized, using current time as timestamp +I (49102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (49198) SEN66: RTC is uninitialized, using current time as timestamp +W (49441) wifi:Haven't to connect to a suitable AP now! +I (49602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (49792) BME688: RTC is uninitialized, using current time as timestamp +I (50102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (50198) SEN66: RTC is uninitialized, using current time as timestamp +W (50442) wifi:Haven't to connect to a suitable AP now! +I (50602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (50766) INFLUX: Writing to InfluxDB +W (50792) BME688: RTC is uninitialized, using current time as timestamp +I (51102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (51198) SEN66: RTC is uninitialized, using current time as timestamp +W (51443) wifi:Haven't to connect to a suitable AP now! +I (51602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (51792) BME688: RTC is uninitialized, using current time as timestamp +I (52102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (52198) SEN66: RTC is uninitialized, using current time as timestamp +W (52444) wifi:Haven't to connect to a suitable AP now! +I (52602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (52792) BME688: RTC is uninitialized, using current time as timestamp +I (53102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (53198) SEN66: RTC is uninitialized, using current time as timestamp +W (53445) wifi:Haven't to connect to a suitable AP now! +I (53602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (53792) BME688: RTC is uninitialized, using current time as timestamp +I (54102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (54198) SEN66: RTC is uninitialized, using current time as timestamp +W (54446) wifi:Haven't to connect to a suitable AP now! +I (54602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (54792) BME688: RTC is uninitialized, using current time as timestamp +I (55102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (55198) SEN66: RTC is uninitialized, using current time as timestamp +W (55447) wifi:Haven't to connect to a suitable AP now! +I (55602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (55792) BME688: RTC is uninitialized, using current time as timestamp +I (56102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (56198) SEN66: RTC is uninitialized, using current time as timestamp +W (56448) wifi:Haven't to connect to a suitable AP now! +I (56602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (56792) BME688: RTC is uninitialized, using current time as timestamp +I (57102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (57198) SEN66: RTC is uninitialized, using current time as timestamp +W (57449) wifi:Haven't to connect to a suitable AP now! +I (57602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (57761) OTA_CLIENT: Device is not registered, attempting to register... +I (57763) OTA_CLIENT: HTTP client handle: 0x3fcf0aac +E (57767) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (57771) esp-tls: Failed to open new connection +E (57774) transport_base: Failed to open a new connection +E (57780) HTTP_CLIENT: Connection failed, sock < 0 +I (57784) INFLUX: HTTP request performed, status code: 0 +E (57789) INFLUX: [E-INF-03] Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (57797) INFLUX_BUFFER: [E-INF-17] Failed to write chunk to Influx: ESP_FAIL +W (57792) BME688: RTC is uninitialized, using current time as timestamp +I (58102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (58198) SEN66: RTC is uninitialized, using current time as timestamp +W (58450) wifi:Haven't to connect to a suitable AP now! +I (58602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (58810) BME688: RTC is uninitialized, using current time as timestamp +I (59102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (59198) SEN66: RTC is uninitialized, using current time as timestamp +W (59451) wifi:Haven't to connect to a suitable AP now! +I (59602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +E (59804) INFLUX_BUFFER: [E-INF-18] Failed to write chunk with 5 retries to Influx: ESP_FAIL +I (59804) KOTA: status OK -> CONNECTION_ERROR reason=influx_flush_failed +E (59808) MAIN: [E-MAIN-27] Failed to flush Influx buffer: ESP_FAIL +W (59810) BME688: RTC is uninitialized, using current time as timestamp +I (60102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (60198) SEN66: RTC is uninitialized, using current time as timestamp +W (60452) wifi:Haven't to connect to a suitable AP now! +I (60602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (60820) BME688: RTC is uninitialized, using current time as timestamp +I (61102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (61198) SEN66: RTC is uninitialized, using current time as timestamp +W (61453) wifi:Haven't to connect to a suitable AP now! +I (61602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (61820) BME688: RTC is uninitialized, using current time as timestamp +I (62102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (62198) SEN66: RTC is uninitialized, using current time as timestamp +W (62454) wifi:Haven't to connect to a suitable AP now! +I (62602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (62820) BME688: RTC is uninitialized, using current time as timestamp +W (62927) wifi:Haven't to connect to a suitable AP now! +I (62927) KOTA: health up=61s status=CONNECTION_ERROR wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=764 heap=46611 psram=7.3M last_upload=never ota=unregistered +I (63102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (63198) SEN66: RTC is uninitialized, using current time as timestamp +W (63455) wifi:Haven't to connect to a suitable AP now! +I (63602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +E (63767) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (63767) esp-tls: Failed to open new connection +E (63769) transport_base: Failed to open a new connection +E (63775) HTTP_CLIENT: Connection failed, sock < 0 +E (63779) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:228 +E (63787) OTA_CLIENT: [E-OTA-12] Device registration failed: ESP_ERR_HTTP_CONNECT +W (63820) BME688: RTC is uninitialized, using current time as timestamp +I (64102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (64198) SEN66: RTC is uninitialized, using current time as timestamp +W (64456) wifi:Haven't to connect to a suitable AP now! +I (64602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (64634) SEN66: First measurement, waiting additional 120 seconds for sensor to stabilize... +W (64820) BME688: RTC is uninitialized, using current time as timestamp +I (65102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (65198) SEN66: RTC is uninitialized, using current time as timestamp +W (65457) wifi:Haven't to connect to a suitable AP now! +I (65602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (65820) BME688: RTC is uninitialized, using current time as timestamp +I (66102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (66198) SEN66: RTC is uninitialized, using current time as timestamp +W (66458) wifi:Haven't to connect to a suitable AP now! +I (66602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (66820) BME688: RTC is uninitialized, using current time as timestamp +I (67102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (67198) SEN66: RTC is uninitialized, using current time as timestamp +W (67459) wifi:Haven't to connect to a suitable AP now! +I (67602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (67820) BME688: RTC is uninitialized, using current time as timestamp +I (68102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (68198) SEN66: RTC is uninitialized, using current time as timestamp +W (68460) wifi:Haven't to connect to a suitable AP now! +I (68602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (68820) BME688: RTC is uninitialized, using current time as timestamp +I (69102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (69198) SEN66: RTC is uninitialized, using current time as timestamp +W (69461) wifi:Haven't to connect to a suitable AP now! +I (69602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (69820) BME688: RTC is uninitialized, using current time as timestamp +I (70102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (70198) SEN66: RTC is uninitialized, using current time as timestamp +W (70462) wifi:Haven't to connect to a suitable AP now! +I (70602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (70820) BME688: RTC is uninitialized, using current time as timestamp +I (71102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (71198) SEN66: RTC is uninitialized, using current time as timestamp +W (71463) wifi:Haven't to connect to a suitable AP now! +I (71602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (71820) BME688: RTC is uninitialized, using current time as timestamp +I (72102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (72198) SEN66: RTC is uninitialized, using current time as timestamp +W (72464) wifi:Haven't to connect to a suitable AP now! +I (72602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (72820) BME688: RTC is uninitialized, using current time as timestamp +I (73102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (73198) SEN66: RTC is uninitialized, using current time as timestamp +W (73465) wifi:Haven't to connect to a suitable AP now! +I (73602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (73794) OTA_CLIENT: Device is not registered, attempting to register... +I (73796) OTA_CLIENT: HTTP client handle: 0x3fcf03a8 +W (73820) BME688: RTC is uninitialized, using current time as timestamp +I (74102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (74198) SEN66: RTC is uninitialized, using current time as timestamp +W (74466) wifi:Haven't to connect to a suitable AP now! +I (74602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (74609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (74820) BME688: RTC is uninitialized, using current time as timestamp +I (75102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (75109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (75198) SEN66: RTC is uninitialized, using current time as timestamp +W (75467) wifi:Haven't to connect to a suitable AP now! +I (75602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (75609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (75820) BME688: RTC is uninitialized, using current time as timestamp +I (76102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (76109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (76198) SEN66: RTC is uninitialized, using current time as timestamp +W (76468) wifi:Haven't to connect to a suitable AP now! +I (76602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (76609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (76820) BME688: RTC is uninitialized, using current time as timestamp +I (77102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (77109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (77198) SEN66: RTC is uninitialized, using current time as timestamp +W (77469) wifi:Haven't to connect to a suitable AP now! +I (77602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (77609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (77820) BME688: RTC is uninitialized, using current time as timestamp +I (78102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (78109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (78198) SEN66: RTC is uninitialized, using current time as timestamp +W (78470) wifi:Haven't to connect to a suitable AP now! +I (78602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (78609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (78820) BME688: RTC is uninitialized, using current time as timestamp +I (79102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (79109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (79198) SEN66: RTC is uninitialized, using current time as timestamp +W (79471) wifi:Haven't to connect to a suitable AP now! +I (79602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (79609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (79820) BME688: RTC is uninitialized, using current time as timestamp +I (80102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (80109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (80198) SEN66: RTC is uninitialized, using current time as timestamp +W (80472) wifi:Haven't to connect to a suitable AP now! +I (80602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (80609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +E (80797) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (80798) esp-tls: Failed to open new connection +E (80799) transport_base: Failed to open a new connection +E (80805) HTTP_CLIENT: Connection failed, sock < 0 +E (80809) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:228 +E (80817) OTA_CLIENT: [E-OTA-12] Device registration failed: ESP_ERR_HTTP_CONNECT +W (80820) BME688: RTC is uninitialized, using current time as timestamp +I (81102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (81109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (81198) SEN66: RTC is uninitialized, using current time as timestamp +W (81473) wifi:Haven't to connect to a suitable AP now! +I (81602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (81609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (81831) BME688: RTC is uninitialized, using current time as timestamp +I (82102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (82109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (82198) SEN66: RTC is uninitialized, using current time as timestamp +W (82474) wifi:Haven't to connect to a suitable AP now! +I (82602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (82609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (82831) BME688: RTC is uninitialized, using current time as timestamp +I (83102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (83109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (83198) SEN66: RTC is uninitialized, using current time as timestamp +W (83475) wifi:Haven't to connect to a suitable AP now! +I (83602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (83609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (83831) BME688: RTC is uninitialized, using current time as timestamp +I (84102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (84109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (84198) SEN66: RTC is uninitialized, using current time as timestamp +W (84476) wifi:Haven't to connect to a suitable AP now! +I (84602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (84609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (84831) BME688: RTC is uninitialized, using current time as timestamp +I (85102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (85109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (85198) SEN66: RTC is uninitialized, using current time as timestamp +W (85477) wifi:Haven't to connect to a suitable AP now! +I (85602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (85609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (85831) BME688: RTC is uninitialized, using current time as timestamp +I (86102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (86109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (86198) SEN66: RTC is uninitialized, using current time as timestamp +W (86478) wifi:Haven't to connect to a suitable AP now! +I (86602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (86609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (86831) BME688: RTC is uninitialized, using current time as timestamp +I (87102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (87109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (87198) SEN66: RTC is uninitialized, using current time as timestamp +W (87479) wifi:Haven't to connect to a suitable AP now! +I (87602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (87609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (87831) BME688: RTC is uninitialized, using current time as timestamp +I (88102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (88109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (88198) SEN66: RTC is uninitialized, using current time as timestamp +W (88480) wifi:Haven't to connect to a suitable AP now! +I (88602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (88609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (88831) BME688: RTC is uninitialized, using current time as timestamp +I (89102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (89109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (89198) SEN66: RTC is uninitialized, using current time as timestamp +W (89481) wifi:Haven't to connect to a suitable AP now! +I (89602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (89609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (89831) BME688: RTC is uninitialized, using current time as timestamp +I (90102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (90109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (90198) SEN66: RTC is uninitialized, using current time as timestamp +W (90482) wifi:Haven't to connect to a suitable AP now! +I (90602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (90609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +I (90824) OTA_CLIENT: Device is not registered, attempting to register... +I (90826) OTA_CLIENT: HTTP client handle: 0x3fcf03a8 +W (90831) BME688: RTC is uninitialized, using current time as timestamp +I (91102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (91109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (91198) SEN66: RTC is uninitialized, using current time as timestamp +W (91483) wifi:Haven't to connect to a suitable AP now! +I (91602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (91609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (91831) BME688: RTC is uninitialized, using current time as timestamp +I (92102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (92109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (92198) SEN66: RTC is uninitialized, using current time as timestamp +W (92484) wifi:Haven't to connect to a suitable AP now! +I (92602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (92609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (92831) BME688: RTC is uninitialized, using current time as timestamp +I (93102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (93109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (93198) SEN66: RTC is uninitialized, using current time as timestamp +W (93485) wifi:Haven't to connect to a suitable AP now! +I (93602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (93609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (93831) BME688: RTC is uninitialized, using current time as timestamp +I (94102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (94109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (94198) SEN66: RTC is uninitialized, using current time as timestamp +W (94486) wifi:Haven't to connect to a suitable AP now! +I (94602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (94609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (94831) BME688: RTC is uninitialized, using current time as timestamp +I (95102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (95109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (95198) SEN66: RTC is uninitialized, using current time as timestamp +W (95487) wifi:Haven't to connect to a suitable AP now! +I (95602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (95609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (95831) BME688: RTC is uninitialized, using current time as timestamp +I (96102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (96109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (96198) SEN66: RTC is uninitialized, using current time as timestamp +W (96488) wifi:Haven't to connect to a suitable AP now! +I (96602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (96609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (96831) BME688: RTC is uninitialized, using current time as timestamp +I (97102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (97109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (97198) SEN66: RTC is uninitialized, using current time as timestamp +W (97489) wifi:Haven't to connect to a suitable AP now! +I (97602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (97609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +E (97827) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (97827) esp-tls: Failed to open new connection +E (97829) transport_base: Failed to open a new connection +W (97831) BME688: RTC is uninitialized, using current time as timestamp +E (97835) HTTP_CLIENT: Connection failed, sock < 0 +E (97845) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:228 +E (97854) OTA_CLIENT: [E-OTA-12] Device registration failed: ESP_ERR_HTTP_CONNECT +I (98102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (98109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (98198) SEN66: RTC is uninitialized, using current time as timestamp +W (98490) wifi:Haven't to connect to a suitable AP now! +I (98602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (98609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (98841) BME688: RTC is uninitialized, using current time as timestamp +I (99102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (99109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (99198) SEN66: RTC is uninitialized, using current time as timestamp +W (99491) wifi:Haven't to connect to a suitable AP now! +I (99602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (99609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (99841) BME688: RTC is uninitialized, using current time as timestamp +I (100102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (100109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (100198) SEN66: RTC is uninitialized, using current time as timestamp +W (100492) wifi:Haven't to connect to a suitable AP now! +I (100602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (100609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (100841) BME688: RTC is uninitialized, using current time as timestamp +I (101102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (101109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (101198) SEN66: RTC is uninitialized, using current time as timestamp +W (101493) wifi:Haven't to connect to a suitable AP now! +I (101602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (101609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (101841) BME688: RTC is uninitialized, using current time as timestamp +I (102102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (102109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (102198) SEN66: RTC is uninitialized, using current time as timestamp +W (102494) wifi:Haven't to connect to a suitable AP now! +I (102602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (102609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (102841) BME688: RTC is uninitialized, using current time as timestamp +I (103102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (103109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (103198) SEN66: RTC is uninitialized, using current time as timestamp +W (103495) wifi:Haven't to connect to a suitable AP now! +I (103602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (103609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (103841) BME688: RTC is uninitialized, using current time as timestamp +I (104102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (104109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (104198) SEN66: RTC is uninitialized, using current time as timestamp +W (104496) wifi:Haven't to connect to a suitable AP now! +I (104602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (104609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (104841) BME688: RTC is uninitialized, using current time as timestamp +I (105102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (105109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (105198) SEN66: RTC is uninitialized, using current time as timestamp +W (105497) wifi:Haven't to connect to a suitable AP now! +I (105602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (105609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (105841) BME688: RTC is uninitialized, using current time as timestamp +I (106102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (106109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (106198) SEN66: RTC is uninitialized, using current time as timestamp +W (106498) wifi:Haven't to connect to a suitable AP now! +I (106602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (106609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (106841) BME688: RTC is uninitialized, using current time as timestamp +I (107102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (107109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (107198) SEN66: RTC is uninitialized, using current time as timestamp +W (107499) wifi:Haven't to connect to a suitable AP now! +I (107602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (107609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (107841) BME688: RTC is uninitialized, using current time as timestamp +I (107861) OTA_CLIENT: Device is not registered, attempting to register... +I (107863) OTA_CLIENT: HTTP client handle: 0x3fcf03a8 +I (108102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (108109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (108198) SEN66: RTC is uninitialized, using current time as timestamp +W (108500) wifi:Haven't to connect to a suitable AP now! +I (108602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (108609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (108841) BME688: RTC is uninitialized, using current time as timestamp +I (109102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (109109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (109198) SEN66: RTC is uninitialized, using current time as timestamp +W (109501) wifi:Haven't to connect to a suitable AP now! +I (109602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (109609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (109841) BME688: RTC is uninitialized, using current time as timestamp +I (110102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (110109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (110198) SEN66: RTC is uninitialized, using current time as timestamp +W (110502) wifi:Haven't to connect to a suitable AP now! +I (110602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (110609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (110841) BME688: RTC is uninitialized, using current time as timestamp +I (111102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (111109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (111198) SEN66: RTC is uninitialized, using current time as timestamp +W (111503) wifi:Haven't to connect to a suitable AP now! +I (111602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (111609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (111841) BME688: RTC is uninitialized, using current time as timestamp +I (112102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (112109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (112198) SEN66: RTC is uninitialized, using current time as timestamp +W (112504) wifi:Haven't to connect to a suitable AP now! +I (112602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (112609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (112841) BME688: RTC is uninitialized, using current time as timestamp +I (113102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (113109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (113198) SEN66: RTC is uninitialized, using current time as timestamp +W (113505) wifi:Haven't to connect to a suitable AP now! +I (113602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (113609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (113841) BME688: RTC is uninitialized, using current time as timestamp +I (114102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (114109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (114198) SEN66: RTC is uninitialized, using current time as timestamp +W (114506) wifi:Haven't to connect to a suitable AP now! +I (114602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (114609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (114841) BME688: RTC is uninitialized, using current time as timestamp +E (114864) esp-tls: couldn't get hostname for :bin.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (114864) esp-tls: Failed to open new connection +E (114867) transport_base: Failed to open a new connection +E (114872) HTTP_CLIENT: Connection failed, sock < 0 +E (114876) OTA_CLIENT: [E-OTA-01] Error ESP_ERR_HTTP_CONNECT at ./main/ota_client.cpp:228 +E (114885) OTA_CLIENT: [E-OTA-12] Device registration failed: ESP_ERR_HTTP_CONNECT +I (115102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (115109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (115198) SEN66: RTC is uninitialized, using current time as timestamp +W (115507) wifi:Haven't to connect to a suitable AP now! +I (115602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (115609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (115841) BME688: RTC is uninitialized, using current time as timestamp +I (116102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (116109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (116198) SEN66: RTC is uninitialized, using current time as timestamp +W (116508) wifi:Haven't to connect to a suitable AP now! +I (116602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (116609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (116841) BME688: RTC is uninitialized, using current time as timestamp +I (117102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (117109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (117198) SEN66: RTC is uninitialized, using current time as timestamp +W (117509) wifi:Haven't to connect to a suitable AP now! +I (117602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (117609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (117841) BME688: RTC is uninitialized, using current time as timestamp +I (118102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (118109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (118198) SEN66: RTC is uninitialized, using current time as timestamp +W (118510) wifi:Haven't to connect to a suitable AP now! +I (118602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (118609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (118841) BME688: RTC is uninitialized, using current time as timestamp +I (119102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (119109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (119198) SEN66: RTC is uninitialized, using current time as timestamp +W (119511) wifi:Haven't to connect to a suitable AP now! +I (119602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (119609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +I (119817) INFLUX_BUFFER: Flushing chunk starting at line 10: +I (119818) INFLUX: Writing to InfluxDB +W (119841) BME688: RTC is uninitialized, using current time as timestamp +I (120102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (120109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (120198) SEN66: RTC is uninitialized, using current time as timestamp +W (120512) wifi:Haven't to connect to a suitable AP now! +I (120602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (120609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (120841) BME688: RTC is uninitialized, using current time as timestamp +I (121102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (121109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (121198) SEN66: RTC is uninitialized, using current time as timestamp +W (121513) wifi:Haven't to connect to a suitable AP now! +I (121602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (121609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (121841) BME688: RTC is uninitialized, using current time as timestamp +I (122102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (122109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (122198) SEN66: RTC is uninitialized, using current time as timestamp +W (122514) wifi:Haven't to connect to a suitable AP now! +I (122602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (122609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (122841) BME688: RTC is uninitialized, using current time as timestamp +W (122935) wifi:Haven't to connect to a suitable AP now! +I (122935) KOTA: health up=121s status=CONNECTION_ERROR wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=764 heap=43167 psram=7.3M last_upload=never ota=unregistered +I (123102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (123109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (123198) SEN66: RTC is uninitialized, using current time as timestamp +W (123515) wifi:Haven't to connect to a suitable AP now! +I (123602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (123609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (123841) BME688: RTC is uninitialized, using current time as timestamp +I (124102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (124109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (124198) SEN66: RTC is uninitialized, using current time as timestamp +W (124516) wifi:Haven't to connect to a suitable AP now! +I (124602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (124609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (124841) BME688: RTC is uninitialized, using current time as timestamp +I (124892) OTA_CLIENT: Device is not registered, attempting to register... +I (124894) OTA_CLIENT: HTTP client handle: 0x3fcf1210 +I (125102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (125109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (125198) SEN66: RTC is uninitialized, using current time as timestamp +W (125517) wifi:Haven't to connect to a suitable AP now! +I (125602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (125609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (125841) BME688: RTC is uninitialized, using current time as timestamp +I (126102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (126109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (126198) SEN66: RTC is uninitialized, using current time as timestamp +W (126518) wifi:Haven't to connect to a suitable AP now! +I (126602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (126609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +E (126819) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (126820) esp-tls: Failed to open new connection +E (126822) transport_base: Failed to open a new connection +E (126828) HTTP_CLIENT: Connection failed, sock < 0 +I (126832) INFLUX: HTTP request performed, status code: 0 +E (126837) INFLUX: [E-INF-03] Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (126845) INFLUX_BUFFER: [E-INF-17] Failed to write chunk to Influx: ESP_FAIL +W (126841) BME688: RTC is uninitialized, using current time as timestamp +I (127102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (127109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (127198) SEN66: RTC is uninitialized, using current time as timestamp +W (127519) wifi:Haven't to connect to a suitable AP now! +I (127602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (127609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (127858) BME688: RTC is uninitialized, using current time as timestamp +I (128102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (128109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (128198) SEN66: RTC is uninitialized, using current time as timestamp +W (128520) wifi:Haven't to connect to a suitable AP now! +I (128602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (128609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +I (128852) INFLUX: Writing to InfluxDB +W (128858) BME688: RTC is uninitialized, using current time as timestamp +I (129102) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (129109) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (129198) SEN66: RTC is uninitialized, using current time as timestamp +W (129521) wifi:Haven't to connect to a suitable AP now! +I (129602) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (129609) SENSOR_GATHERER: 2/4 tasks still running, waiting... +W (129858) BME688: RTC is uninitialized, using current time as timestamp +--- disconnected --- \ No newline at end of file diff --git a/tests/fixtures/live_brownout_loop_A4CB8FC946F8_fw1.0.txt b/tests/fixtures/live_brownout_loop_A4CB8FC946F8_fw1.0.txt new file mode 100644 index 0000000..bdff502 --- /dev/null +++ b/tests/fixtures/live_brownout_loop_A4CB8FC946F8_fw1.0.txt @@ -0,0 +1,1936 @@ +--- connected to COM5 --- +I (16926) SENSOR_GATHERER: Sensor should now be asleep +--- reset issued by tool --- +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x15 (USB_UART_CHIP_RESET),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x4037f34a +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (25) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (28) boot: efuse block revision: v1.4 +I (32) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (43) boot: Enabling RNG early entropy source... +I (48) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (63) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (76) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (89) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (102) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (109) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (191) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (203) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (469) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (485) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (495) boot: Loaded app from partition at offset 0x40000 +I (495) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (506) octal_psram: density : 0x03 (64 Mbit) +I (508) octal_psram: good-die : 0x01 (Pass) +I (512) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (535) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (540) octal_psram: DriveStrength: 0x00 (1/1) +I (544) esp_psram: Found 8MB PSRAM device +I (548) esp_psram: Speed: 40MHz +I (551) cpu_start: Multicore app +I (1285) esp_psram: SPI SRAM memory test OK +I (1293) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1294) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1304) app_init: App version: 1.0 +I (1308) app_init: Compile time: Jun 29 2026 11:03:32 +I (1313) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1322) efuse_init: Min chip rev: v0.0 +I (1326) efuse_init: Max chip rev: v0.99 +I (1330) efuse_init: Chip rev: v0.2 +I (1334) heap_init: Initializing. RAM available for dynamic allocation: +I (1340) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1356) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1369) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1377) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1397) main_task: Started on CPU0 +I (1400) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1408) main_task: Calling app_main() +E (1411) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1436) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1441) MAIN: Matter node created successfully +W (1442) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1445) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1454) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1463) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1472) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1480) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1489) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1498) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1507) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1515) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1524) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1533) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1542) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1551) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1559) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1568) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1577) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1586) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1596) pp: pp rom version: e7ae62f +I (1598) net80211: net80211 rom version: e7ae62f +I (1603) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0 +I (1621) wifi:wifi firmware version: 4df78f2 +I (1621) wifi:wifi certification version: v7.0 +I (1621) wifi:config NVS flash: enabled +I (1622) wifi:config nano formatting: disabled +I (1626) wifi:Init data frame dynamic rx buffer num: 32 +I (1631) wifi:Init static rx mgmt buffer num: 5 +I (1635) wifi:Init management short buffer num: 32 +I (1639) wifi:Init dynamic tx buffer num: 32 +I (1644) wifi:Init static tx FG buffer num: 2 +I (1648) wifi:Init static rx buffer size: 1600 +I (1652) wifi:Init static rx buffer num: 10 +I (1656) wifi:Init dynamic rx buffer num: 32 +I (1660) wifi_init: rx ba win: 6 +I (1663) wifi_init: accept mbox: 6 +I (1666) wifi_init: tcpip mbox: 32 +I (1669) wifi_init: udp mbox: 6 +I (1672) wifi_init: tcp mbox: 6 +I (1675) wifi_init: tcp tx win: 5760 +I (1678) wifi_init: tcp rx win: 5760 +I (1681) wifi_init: tcp mss: 1440 +I (1692) chip[DL]: NVS set: chip-counters/reboot-count = 15834 (0x3DDA) +I (1695) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1698) BLE_INIT: BT controller compile version [1bb2f50] +I (1703) BLE_INIT: Using main XTAL as clock source +I (1707) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1716) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1721) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +E BOD: Brownout detector was triggered + + +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x40376756 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (468) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (495) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (544) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1304) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1340) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1377) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1439) MAIN: Matter node created successfully +W (1441) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1462) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1471) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1506) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1541) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1549) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1576) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1585) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1597) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0 +I (1619) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1621) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1642) wifi:Init static tx FG buffer num: 2 +I (1647) wifi:Init static rx buffer size: 1600 +I (1651) wifi:Init static rx buffer num: 10 +I (1655) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1661) wifi_init: accept mbox: 6 +I (1665) wifi_init: tcpip mbox: 32 +I (1668) wifi_init: udp mbox: 6 +I (1671) wifi_init: tcp mbox: 6 +I (1673) wifi_init: tcp tx win: 5760 +I (1677) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1692) chip[DL]: NVS set: chip-counters/reboot-count = 15835 (0x3DDB) +I (1695) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1697) BLE_INIT: BT controller compile version [1bb2f50] +I (1702) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1715) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +E BOD: Brownout detector was triggered + + +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x40376756 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (468) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (494) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (543) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1303) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1339) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1376) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1439) MAIN: Matter node created successfully +W (1441) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1462) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1471) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1506) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1541) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1550) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1576) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1585) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1597) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0 +I (1619) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1621) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1642) wifi:Init static tx FG buffer num: 2 +I (1647) wifi:Init static rx buffer size: 1600 +I (1651) wifi:Init static rx buffer num: 10 +I (1655) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1661) wifi_init: accept mbox: 6 +I (1665) wifi_init: tcpip mbox: 32 +I (1668) wifi_init: udp mbox: 6 +I (1671) wifi_init: tcp mbox: 6 +I (1673) wifi_init: tcp tx win: 5760 +I (1677) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1692) chip[DL]: NVS set: chip-counters/reboot-count = 15836 (0x3DDC) +I (1695) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1697) BLE_INIT: BT controller compile version [1bb2f50] +I (1702) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1715) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +E BOD: Brownout detector was triggered + + +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x40376756 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (468) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (494) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (543) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1303) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1339) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1376) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1439) MAIN: Matter node created successfully +W (1441) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1462) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1471) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1506) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1541) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1549) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1576) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1585) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1597) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0 +I (1619) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1621) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1642) wifi:Init static tx FG buffer num: 2 +I (1647) wifi:Init static rx buffer size: 1600 +I (1651) wifi:Init static rx buffer num: 10 +I (1655) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1662) wifi_init: accept mbox: 6 +I (1665) wifi_init: tcpip mbox: 32 +I (1668) wifi_init: udp mbox: 6 +I (1671) wifi_init: tcp mbox: 6 +I (1673) wifi_init: tcp tx win: 5760 +I (1677) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1692) chip[DL]: NVS set: chip-counters/reboot-count = 15837 (0x3DDD) +I (1695) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1697) BLE_INIT: BT controller compile version [1bb2f50] +I (1702) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1715) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +E BOD: Brownout detector was triggered + + +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x40376756 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (468) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (494) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (543) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1303) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1339) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1376) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1439) MAIN: Matter node created successfully +W (1441) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1462) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1471) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1506) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1541) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1549) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1576) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1585) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1597) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0 +I (1619) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1621) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1642) wifi:Init static tx FG buffer num: 2 +I (1647) wifi:Init static rx buffer size: 1600 +I (1651) wifi:Init static rx buffer num: 10 +I (1655) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1661) wifi_init: accept mbox: 6 +I (1665) wifi_init: tcpip mbox: 32 +I (1668) wifi_init: udp mbox: 6 +I (1671) wifi_init: tcp mbox: 6 +I (1673) wifi_init: tcp tx win: 5760 +I (1677) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1692) chip[DL]: NVS set: chip-counters/reboot-count = 15838 (0x3DDE) +I (1695) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1697) BLE_INIT: BT controller compile version [1bb2f50] +I (1702) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1715) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +I (1768) phy_init: Saving new calibration data due to checksum failure or outdated calibration data, mode(0) +I (1785) NimBLE: GAP procedure initiated: stop advertising. + +I (1787) CHIP[DL]: BLE host-controller synced +I (2287) chip[DL]: Starting ESP WiFi layer +I (2289) wifi:mode : sta (a4:cb:8f:c9:46:f8) +I (2290) wifi:enable tsf +W (2290) wifi:Haven't to connect to a suitable AP now! +I (2291) chip[DL]: +I (2291) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43 +Done driving station state, nothing else to do... +I (2300) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2 +W (2304) wifi:Haven't to connect to a suitable AP now! +I (2315) chip[DL]: Done driving station state, nothing else to do... +I (2321) chip[SVR]: Initializing subscription resumption storage... +I (2328) chip[SVR]: Server initializing... +I (2331) chip[TS]: Last Known Good Time: 2026-08-04T05:56:06 +I (2337) chip[DMG]: AccessControl: initializing +I (2340) chip[DMG]: Examples::AccessControlDelegate::Init +I (2345) chip[DMG]: AccessControl: setting +I (2349) chip[DMG]: DefaultAclStorage: initializing +I (2354) chip[DMG]: DefaultAclStorage: 0 entries loaded +I (2359) chip[SVR]: WARNING: mTestEventTriggerDelegate is null +I (2378) chip[ZCL]: Using ZAP configuration... +I (2378) esp_matter_cluster: Cluster plugin init common callback +I (2379) chip[DMG]: AccessControlCluster: initializing +I (2382) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported +I (2392) chip[DIS]: Updating services using commissioning mode 1 +I (2396) chip[DIS]: CHIP minimal mDNS started advertising. +I (2401) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0 +I (2411) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: 37BE0E5B446CAB54. +I (2421) chip[DIS]: mDNS service published: _matterc._udp +I (2426) chip[IN]: CASE Server enabling CASE session setups +I (2432) chip[SVR]: Joining Multicast groups +I (2435) chip[SVR]: Server Listening... +I (2440) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 ********** +I (2450) data_model: Dynamic endpoint 0 added +E (2453) data_model: Cluster cannot be NULL. +I (2458) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 ********** +I (2468) data_model: Dynamic endpoint 1 added +E (2472) data_model: Cluster cannot be NULL. +I (2477) chip[DL]: WIFI_EVENT_STA_START +I (2478) MAIN: ESP-Matter started successfully +W (2480) wifi:Haven't to connect to a suitable AP now! +I (2489) chip[DL]: Done driving station state, nothing else to do... +I (2496) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable) +I (2506) NimBLE: GAP procedure initiated: advertise; +I (2506) NimBLE: disc_mode=2 + +I (2507) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40 +I (2511) NimBLE: + +I (2513) chip[DL]: CHIPoBLE advertising started +I (2514) MAIN: Commissioning window opened +> +I (4060) I2C: Creating I2C mutex +I (14061) SPI: Creating SPI mutex +I (14103) INFLUX_BUFFER: SD card mounted successfully +I (14104) INFLUX_BUFFER: /sdcard exists, SD card initialized successfully +I (14107) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +W (14121) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +I (14145) BME688: Delaying for 10000 microseconds +I (14763) Flicker: Flicker sensor initialized on pin 14 +I (14765) temperature_sensor: Range [20°C ~ 100°C], error < 2°C +I (14767) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data +I (14767) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data +W (14772) wifi:Haven't to connect to a suitable AP now! +I (14768) SEN66: Creating sensor mutex +W (14782) AS7341: Reading spectral data from AS7341 +I (14775) INFLUX_BUFFER: Flushing chunk starting at line 10: +I (14796) INFLUX: Writing to InfluxDB +I (14784) SEN66: Waking up sensor by setting power GPIO to enabled level... +I (14783) DeviceStatus: Active tasks: 26 +I (14813) DeviceStatus: Task list: +gather_sensor_t X 1 1216 22 +IDLE1 R 0 812 6 +IDLE0 R 0 692 5 +gather_sensor_t B 1 2552 21 +ds1307_update B 1 800 17 +gather_task B 1 1756 19 +gather_task B 1 14032 20 +alive_diode_tas B 5 352 8 +reset_task B 0 3432 15 +tiT B 18 2420 10 +gather_sensor_t B 1 1688 24 +ota_task B 1 7516 18 +CHIP B 1 3700 12 +Tmr Svc B 1 1364 7 +gather_sensor_t B 1 1092 25 +sys_evt B 20 2684 9 +ipc0 S 24 372 1 +ipc1 S 24 524 2 +console B 5 648 16 +esp_timer S 22 3332 3 +gather_sensor_t B 1 2284 23 +gather_sensor_t B 1 2544 26 +wifi B 23 4276 11 +btController B 23 1960 13 +main B 1 876 4 +nimble_host B 21 1480 14 + +W (14878) DeviceStatus: RTC is uninitialized, using current time as timestamp +W (15105) AS7341: Reading basic counts data from AS7341 +W (15121) AS7341: Reading flicker detection state from AS7341 +W (15339) as7341: FD Status Register: 0x20 (0b00100000) +I (15345) AS7341: Updating Matter attributes with AS7341 data: 16909 +I (15345) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms +I (15359) Flicker: Starting to collect samples in gather task +I (15355) BME688: Delaying for 199517 microseconds +I (15347) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 ********** +I (15374) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 16909 ********** +E (15386) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +E (15398) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +W (15400) AS7341: Formatting AS7341 data in line protocol +AS7341 line protocol: +as7341_adc,device=A4CB8FC946F8 f1=1,f2=2,f3=2,f4=3,f5=3,f6=4,f7=7,f8=9,clear=22,nir=5 946684801000000000 +as7341_basic_counts,device=A4CB8FC946F8 f1=2.23,f2=4.46,f3=4.46,f4=6.69,f5=6.69,f6=8.92,f7=15.62,f8=20.08,clear=49.08,nir=11.15 946684801000000000 +as7341_flicker,device=A4CB8FC946F8 state="unknown" 946684801000000000 +W (15890) DeviceStatus: RTC is uninitialized, using current time as timestamp +I (16383) Flicker: Finished collecting samples, notifying main task +I (16385) Flicker: Collected 1024 samples for dominant frequency analysis +I (16392) SENSOR_GATHERER: Sensor should now be asleep +I (16404) SENSOR_GATHERER: Sensor should now be asleep +I (16444) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null ********** +I (16446) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1018 ********** +E (16459) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +E (16471) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +W (16473) BME688: RTC is uninitialized, using current time as timestamp +I (16891) SENSOR_GATHERER: Sensor should now be asleep +W (17484) BME688: RTC is uninitialized, using current time as timestamp +W (18485) BME688: RTC is uninitialized, using current time as timestamp +W (19486) BME688: RTC is uninitialized, using current time as timestamp +I (19809) SEN66: Sent start measurement command, waiting for sensor to be ready... +I (19905) MICROPHONE: Read 300000 bytes in 4936 ms +I (19906) MICROPHONE: Read 300000 bytes of audio data +I (20098) MICROPHONE: Sum SQ: 0.049147, RMS pressure: 0.000701, Normalized: 0.000701, dB peak: -63.09 +W (20100) SEN66: RTC is uninitialized, using current time as timestamp +W (20487) BME688: RTC is uninitialized, using current time as timestamp +W (21112) SEN66: RTC is uninitialized, using current time as timestamp +W (21488) BME688: RTC is uninitialized, using current time as timestamp +E (21798) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (21800) esp-tls: Failed to open new connection +E (21801) transport_base: Failed to open a new connection +E (21812) HTTP_CLIENT: Connection failed, sock < 0 +I (21813) INFLUX: HTTP request performed, status code: 0 +E (21824) INFLUX: Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (21826) INFLUX_BUFFER: Failed to write chunk to Influx: ESP_FAIL +W (22113) SEN66: RTC is uninitialized, using current time as timestamp +W (22489) BME688: RTC is uninitialized, using current time as timestamp +I (22769) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (23114) SEN66: RTC is uninitialized, using current time as timestamp +I (23270) SENSOR_GATHERER: 1/2 tasks still running, waiting... +E BOD: Brownout detector was triggered + + +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x40376756 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (468) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (494) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (543) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1303) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1339) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1376) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1439) MAIN: Matter node created successfully +W (1440) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1462) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1470) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1505) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1541) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1549) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1576) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1584) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1596) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc524c, prio:23, stack:6656, core=0 +I (1619) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1620) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1642) wifi:Init static tx FG buffer num: 2 +I (1646) wifi:Init static rx buffer size: 1600 +I (1651) wifi:Init static rx buffer num: 10 +I (1654) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1661) wifi_init: accept mbox: 6 +I (1664) wifi_init: tcpip mbox: 32 +I (1667) wifi_init: udp mbox: 6 +I (1670) wifi_init: tcp mbox: 6 +I (1673) wifi_init: tcp tx win: 5760 +I (1677) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1691) chip[DL]: NVS set: chip-counters/reboot-count = 15839 (0x3DDF) +I (1694) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1697) BLE_INIT: BT controller compile version [1bb2f50] +I (1701) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1715) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +E BOD: Brownout detector was triggered + + +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x40376756 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (469) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (495) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (544) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1304) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1340) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1377) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1439) MAIN: Matter node created successfully +W (1440) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1461) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1470) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1505) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1540) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1549) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1575) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1584) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1596) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc524c, prio:23, stack:6656, core=0 +I (1619) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1620) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1642) wifi:Init static tx FG buffer num: 2 +I (1646) wifi:Init static rx buffer size: 1600 +I (1650) wifi:Init static rx buffer num: 10 +I (1654) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1661) wifi_init: accept mbox: 6 +I (1664) wifi_init: tcpip mbox: 32 +I (1667) wifi_init: udp mbox: 6 +I (1670) wifi_init: tcp mbox: 6 +I (1673) wifi_init: tcp tx win: 5760 +I (1676) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1691) chip[DL]: NVS set: chip-counters/reboot-count = 15840 (0x3DE0) +I (1694) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1696) BLE_INIT: BT controller compile version [1bb2f50] +I (1701) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1714) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +I (1767) phy_init: Saving new calibration data due to checksum failure or outdated calibration data, mode(0) +I (1804) NimBLE: GAP procedure initiated: stop advertising. + +I (1806) CHIP[DL]: BLE host-controller synced +I (2306) chip[DL]: Starting ESP WiFi layer +I (2308) wifi:mode : sta (a4:cb:8f:c9:46:f8) +I (2309) wifi:enable tsf +W (2309) wifi:Haven't to connect to a suitable AP now! +I (2310) chip[DL]: +I (2310) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43 +Done driving station state, nothing else to do... +I (2319) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2 +W (2323) wifi:Haven't to connect to a suitable AP now! +I (2334) chip[DL]: Done driving station state, nothing else to do... +I (2340) chip[SVR]: Initializing subscription resumption storage... +I (2347) chip[SVR]: Server initializing... +I (2350) chip[TS]: Last Known Good Time: 2026-08-04T05:56:06 +I (2356) chip[DMG]: AccessControl: initializing +I (2359) chip[DMG]: Examples::AccessControlDelegate::Init +I (2364) chip[DMG]: AccessControl: setting +I (2368) chip[DMG]: DefaultAclStorage: initializing +I (2373) chip[DMG]: DefaultAclStorage: 0 entries loaded +I (2378) chip[SVR]: WARNING: mTestEventTriggerDelegate is null +I (2397) chip[ZCL]: Using ZAP configuration... +I (2397) esp_matter_cluster: Cluster plugin init common callback +I (2398) chip[DMG]: AccessControlCluster: initializing +I (2401) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported +I (2411) chip[DIS]: Updating services using commissioning mode 1 +I (2415) chip[DIS]: CHIP minimal mDNS started advertising. +I (2420) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0 +I (2430) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: 67895F766C4DDEDA. +I (2440) chip[DIS]: mDNS service published: _matterc._udp +I (2445) chip[IN]: CASE Server enabling CASE session setups +I (2451) chip[SVR]: Joining Multicast groups +I (2454) chip[SVR]: Server Listening... +I (2459) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 ********** +I (2469) data_model: Dynamic endpoint 0 added +E (2472) data_model: Cluster cannot be NULL. +I (2477) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 ********** +I (2487) data_model: Dynamic endpoint 1 added +E (2491) data_model: Cluster cannot be NULL. +I (2496) chip[DL]: WIFI_EVENT_STA_START +I (2497) MAIN: ESP-Matter started successfully +W (2499) wifi:Haven't to connect to a suitable AP now! +I (2508) chip[DL]: Done driving station state, nothing else to do... +I (2515) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable) +I (2525) NimBLE: GAP procedure initiated: advertise; +I (2525) NimBLE: disc_mode=2 + +I (2526) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40 +I (2530) NimBLE: + +I (2532) chip[DL]: CHIPoBLE advertising started +I (2533) MAIN: Commissioning window opened +> +I (4079) I2C: Creating I2C mutex +I (14080) SPI: Creating SPI mutex +I (14122) INFLUX_BUFFER: SD card mounted successfully +I (14123) INFLUX_BUFFER: /sdcard exists, SD card initialized successfully +I (14126) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +W (14140) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +I (14164) BME688: Delaying for 10000 microseconds +I (14782) Flicker: Flicker sensor initialized on pin 14 +I (14784) temperature_sensor: Range [20°C ~ 100°C], error < 2°C +I (14786) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data +W (14791) wifi:Haven't to connect to a suitable AP now! +I (14794) INFLUX_BUFFER: Flushing chunk starting at line 10: +I (14786) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data +W (14799) AS7341: Reading spectral data from AS7341 +I (14787) SEN66: Creating sensor mutex +I (14815) SEN66: Waking up sensor by setting power GPIO to enabled level... +I (14802) INFLUX: Writing to InfluxDB +I (14802) DeviceStatus: Active tasks: 26 +I (14832) DeviceStatus: Task list: +main R 1 876 4 +gather_sensor_t X 1 1352 24 +IDLE1 R 0 700 6 +IDLE0 R 0 692 5 +gather_sensor_t B 1 2544 21 +ds1307_update B 1 800 17 +gather_task B 1 1756 19 +gather_task B 1 14032 20 +alive_diode_tas B 5 356 8 +reset_task B 0 3432 15 +tiT B 18 2424 10 +gather_sensor_t B 1 1724 23 +ota_task B 1 7516 18 +CHIP B 1 3708 12 +Tmr Svc B 1 1364 7 +gather_sensor_t B 1 1300 25 +sys_evt B 20 2672 9 +ipc0 S 24 372 1 +ipc1 S 24 524 2 +console B 5 648 16 +esp_timer S 22 3332 3 +gather_sensor_t B 1 2332 22 +gather_sensor_t B 1 2544 26 +wifi B 23 4280 11 +btController B 23 1952 13 +nimble_host B 21 1480 14 + +W (14898) DeviceStatus: RTC is uninitialized, using current time as timestamp +W (15125) AS7341: Reading basic counts data from AS7341 +W (15141) AS7341: Reading flicker detection state from AS7341 +W (15359) as7341: FD Status Register: 0x20 (0b00100000) +I (15365) AS7341: Updating Matter attributes with AS7341 data: 16909 +I (15365) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms +I (15379) Flicker: Starting to collect samples in gather task +I (15375) BME688: Delaying for 199517 microseconds +I (15366) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 ********** +I (15394) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 16909 ********** +E (15406) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +E (15418) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +W (15420) AS7341: Formatting AS7341 data in line protocol +AS7341 line protocol: +as7341_adc,device=A4CB8FC946F8 f1=1,f2=2,f3=3,f4=4,f5=3,f6=4,f7=7,f8=9,clear=22,nir=5 946684801000000000 +as7341_basic_counts,device=A4CB8FC946F8 f1=2.23,f2=4.46,f3=6.69,f4=8.92,f5=6.69,f6=8.92,f7=15.62,f8=20.08,clear=49.08,nir=11.15 946684801000000000 +as7341_flicker,device=A4CB8FC946F8 state="unknown" 946684801000000000 +W (15910) DeviceStatus: RTC is uninitialized, using current time as timestamp +I (16403) Flicker: Finished collecting samples, notifying main task +I (16405) Flicker: Collected 1024 samples for dominant frequency analysis +I (16412) SENSOR_GATHERER: Sensor should now be asleep +I (16424) SENSOR_GATHERER: Sensor should now be asleep +I (16464) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null ********** +I (16466) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1018 ********** +E (16479) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +E (16491) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +W (16493) BME688: RTC is uninitialized, using current time as timestamp +I (16911) SENSOR_GATHERER: Sensor should now be asleep +W (17504) BME688: RTC is uninitialized, using current time as timestamp +W (18505) BME688: RTC is uninitialized, using current time as timestamp +W (19506) BME688: RTC is uninitialized, using current time as timestamp +I (19826) SEN66: Sent start measurement command, waiting for sensor to be ready... +I (19923) MICROPHONE: Read 300000 bytes in 4936 ms +I (19924) MICROPHONE: Read 300000 bytes of audio data +I (20116) MICROPHONE: Sum SQ: 0.135236, RMS pressure: 0.001163, Normalized: 0.001163, dB peak: -58.69 +W (20118) SEN66: RTC is uninitialized, using current time as timestamp +W (20507) BME688: RTC is uninitialized, using current time as timestamp +W (21130) SEN66: RTC is uninitialized, using current time as timestamp +W (21508) BME688: RTC is uninitialized, using current time as timestamp +E (21833) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (21835) esp-tls: Failed to open new connection +E (21846) transport_base: Failed to open a new connection +E (21847) HTTP_CLIENT: Connection failed, sock < 0 +I (21848) INFLUX: HTTP request performed, status code: 0 +E (21859) INFLUX: Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (21861) INFLUX_BUFFER: Failed to write chunk to Influx: ESP_FAIL +W (22131) SEN66: RTC is uninitialized, using current time as timestamp +W (22509) BME688: RTC is uninitialized, using current time as timestamp +I (22799) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (23132) SEN66: RTC is uninitialized, using current time as timestamp +I (23300) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (23510) BME688: RTC is uninitialized, using current time as timestamp +E BOD: Brownout detector was triggered + + +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x40376756 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (468) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (494) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (543) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1303) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1339) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1376) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1439) MAIN: Matter node created successfully +W (1440) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1462) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1470) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1505) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1540) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1549) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1575) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1584) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1596) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0 +I (1619) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1620) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1642) wifi:Init static tx FG buffer num: 2 +I (1646) wifi:Init static rx buffer size: 1600 +I (1650) wifi:Init static rx buffer num: 10 +I (1654) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1661) wifi_init: accept mbox: 6 +I (1664) wifi_init: tcpip mbox: 32 +I (1667) wifi_init: udp mbox: 6 +I (1670) wifi_init: tcp mbox: 6 +I (1673) wifi_init: tcp tx win: 5760 +I (1676) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1691) chip[DL]: NVS set: chip-counters/reboot-count = 15841 (0x3DE1) +I (1694) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1696) BLE_INIT: BT controller compile version [1bb2f50] +I (1701) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1714) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +I (1766) phy_init: Saving new calibration data due to checksum failure or outdated calibration data, mode(0) +I (1806) NimBLE: GAP procedure initiated: stop advertising. + +I (1807) CHIP[DL]: BLE host-controller synced +I (2308) chip[DL]: Starting ESP WiFi layer +I (2310) wifi:mode : sta (a4:cb:8f:c9:46:f8) +I (2311) wifi:enable tsf +W (2311) wifi:Haven't to connect to a suitable AP now! +I (2312) chip[DL]: +I (2312) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43 +Done driving station state, nothing else to do... +I (2321) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2 +W (2325) wifi:Haven't to connect to a suitable AP now! +I (2336) chip[DL]: Done driving station state, nothing else to do... +I (2342) chip[SVR]: Initializing subscription resumption storage... +I (2349) chip[SVR]: Server initializing... +I (2352) chip[TS]: Last Known Good Time: 2026-08-04T05:56:06 +I (2358) chip[DMG]: AccessControl: initializing +I (2361) chip[DMG]: Examples::AccessControlDelegate::Init +I (2366) chip[DMG]: AccessControl: setting +I (2370) chip[DMG]: DefaultAclStorage: initializing +I (2375) chip[DMG]: DefaultAclStorage: 0 entries loaded +I (2380) chip[SVR]: WARNING: mTestEventTriggerDelegate is null +I (2399) chip[ZCL]: Using ZAP configuration... +I (2399) esp_matter_cluster: Cluster plugin init common callback +I (2400) chip[DMG]: AccessControlCluster: initializing +I (2403) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported +I (2413) chip[DIS]: Updating services using commissioning mode 1 +I (2417) chip[DIS]: CHIP minimal mDNS started advertising. +I (2422) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0 +I (2432) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: 3B5442BDAD98C80C. +I (2442) chip[DIS]: mDNS service published: _matterc._udp +I (2447) chip[IN]: CASE Server enabling CASE session setups +I (2453) chip[SVR]: Joining Multicast groups +I (2456) chip[SVR]: Server Listening... +I (2461) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 ********** +I (2471) data_model: Dynamic endpoint 0 added +E (2474) data_model: Cluster cannot be NULL. +I (2479) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 ********** +I (2489) data_model: Dynamic endpoint 1 added +E (2493) data_model: Cluster cannot be NULL. +I (2498) chip[DL]: WIFI_EVENT_STA_START +I (2499) MAIN: ESP-Matter started successfully +W (2501) wifi:Haven't to connect to a suitable AP now! +I (2510) chip[DL]: Done driving station state, nothing else to do... +I (2517) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable) +I (2527) NimBLE: GAP procedure initiated: advertise; +I (2527) NimBLE: disc_mode=2 + +I (2528) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40 +I (2532) NimBLE: + +I (2534) chip[DL]: CHIPoBLE advertising started +I (2535) MAIN: Commissioning window opened +> +I (4082) I2C: Creating I2C mutex +I (14083) SPI: Creating SPI mutex +I (14125) INFLUX_BUFFER: SD card mounted successfully +I (14126) INFLUX_BUFFER: /sdcard exists, SD card initialized successfully +I (14129) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +W (14143) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +I (14167) BME688: Delaying for 10000 microseconds +I (14785) Flicker: Flicker sensor initialized on pin 14 +I (14787) temperature_sensor: Range [20°C ~ 100°C], error < 2°C +I (14789) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data +I (14789) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data +W (14794) wifi: +I (14790) SEN66: Creating sensor mutex +Haven't to connect to a suitable AP now! +I (14805) SEN66: Waking up sensor by setting power GPIO to enabled level... + +I (14803) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms +I (14830) Flicker: Starting to collect samples in gather task +I (14818) DeviceStatus: Active tasks: 26 +I (14797) INFLUX_BUFFER: Flushing chunk starting at line 10: +I (14847) INFLUX: Writing to InfluxDB +I (14846) DeviceStatus: Task list: +gather_sensor_t X 1 1140 22 +main R 1 876 4 +IDLE1 R 0 700 6 +IDLE0 R 0 692 5 +ds1307_update B 1 808 17 +gather_task B 1 1860 19 +gather_task B 1 14040 20 +alive_diode_tas B 5 352 8 +reset_task B 0 3424 15 +tiT B 18 2420 10 +gather_sensor_t B 23 1720 24 +ota_task B 1 7508 18 +CHIP B 1 3636 12 +Tmr Svc B 1 1364 7 +gather_sensor_t B 1 2544 21 +sys_evt B 20 2684 9 +ipc0 S 24 372 1 +ipc1 S 24 524 2 +console B 5 640 16 +esp_timer S 22 3332 3 +gather_sensor_t B 1 2284 23 +gather_sensor_t B 1 2324 25 +wifi B 23 4324 11 +btController B 23 1960 13 +gather_sensor_t S 1 1344 26 +flicker_gather_ S 24 2060 27 +nimble_host B 21 1472 14 + +W (14924) DeviceStatus: RTC is uninitialized, using current time as timestamp +I (15857) Flicker: Finished collecting samples, notifying main task +I (15859) Flicker: Collected 1024 samples for dominant frequency analysis +W (15866) AS7341: Reading spectral data from AS7341 +I (15867) SENSOR_GATHERER: Sensor should now be asleep +I (15876) BME688: Delaying for 199517 microseconds +W (15926) DeviceStatus: RTC is uninitialized, using current time as timestamp +W (16176) AS7341: Reading basic counts data from AS7341 +W (16192) AS7341: Reading flicker detection state from AS7341 +W (16410) as7341: FD Status Register: 0x20 (0b00100000) +I (16416) AS7341: Updating Matter attributes with AS7341 data: 16707 +I (16417) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 ********** +I (16430) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 16707 ********** +E (16443) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +E (16445) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +W (16457) AS7341: RTC is uninitialized, using current time as timestamp +I (16458) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null ********** +I (16471) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1018 ********** +E (16484) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +E (16496) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +W (16497) BME688: RTC is uninitialized, using current time as timestamp +I (16927) SENSOR_GATHERER: Sensor should now be asleep +W (17458) AS7341: RTC is uninitialized, using current time as timestamp +W (17509) BME688: RTC is uninitialized, using current time as timestamp +W (18459) AS7341: RTC is uninitialized, using current time as timestamp +W (18510) BME688: RTC is uninitialized, using current time as timestamp +W (19460) AS7341: RTC is uninitialized, using current time as timestamp +W (19511) BME688: RTC is uninitialized, using current time as timestamp +I (19817) SEN66: Sent start measurement command, waiting for sensor to be ready... +I (19927) MICROPHONE: Read 300000 bytes in 4936 ms +I (19928) MICROPHONE: Read 300000 bytes of audio data +I (20119) MICROPHONE: Sum SQ: 0.185352, RMS pressure: 0.001361, Normalized: 0.001361, dB peak: -57.32 +W (20121) SEN66: RTC is uninitialized, using current time as timestamp +W (20461) AS7341: RTC is uninitialized, using current time as timestamp +W (20512) BME688: RTC is uninitialized, using current time as timestamp +W (21133) SEN66: RTC is uninitialized, using current time as timestamp +W (21462) AS7341: RTC is uninitialized, using current time as timestamp +W (21513) BME688: RTC is uninitialized, using current time as timestamp +E (21849) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0 +E (21851) esp-tls: Failed to open new connection +E (21852) transport_base: Failed to open a new connection +E (21863) HTTP_CLIENT: Connection failed, sock < 0 +I (21864) INFLUX: HTTP request performed, status code: 0 +E (21875) INFLUX: Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT +E (21877) INFLUX_BUFFER: Failed to write chunk to Influx: ESP_FAIL +W (22134) SEN66: RTC is uninitialized, using current time as timestamp +W (22463) AS7341: RTC is uninitialized, using current time as timestamp +W (22514) BME688: RTC is uninitialized, using current time as timestamp +I (22791) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (23135) SEN66: RTC is uninitialized, using current time as timestamp +I (23292) SENSOR_GATHERER: 1/2 tasks still running, waiting... +W (23464) AS7341: RTC is uninitialized, using current time as timestamp +W (23515) BME688: RTC is uninitialized, using current time as timestamp +I (23793) SENSOR_GATHERER: 1/2 tasks still running, waiting... +I (23888) INFLUX: Writing to InfluxDB +E BOD: Brownout detector was triggered + +Guru Meditation Error: Core 0 panic'ed (Interrupt wdt timeout on CPU0). + +Core 0 register dump: +PC : 0x4037f211 PS : 0x00060d34 A0 : 0x80382ff0 A1 : 0x3fc9ec40 +A2 : 0x3fc9c7c8 A3 : 0xb33fffff A4 : 0x0000cdcd A5 : 0x00060923 +A6 : 0x3fceff68 A7 : 0x0000cdcd A8 : 0xfffffff1 A9 : 0x3fc9c7c8 +A10 : 0x01ffffff A11 : 0x0000cdcd A12 : 0x00001eae A13 : 0x40383206 +A14 : 0x3fcef74c A15 : 0x3fc9e9a0 SAR : 0x0000000c EXCCAUSE: 0x00000005 +EXCVADDR: 0x00000000 LBEG : 0x00000000 LEND : 0x00000000 LCOUNT : 0x00000000 +Core 0 was running in ISR context: +EPC1 : 0x42156637 EPC2 : 0x4037f34a EPC3 : 0x40041a76 EPC4 : 0x4037f211 + + +Backtrace: 0x4037f20e:0x3fc9ec40 0x40382fed:0x3fc9ec60 0x40384c1c:0x3fc9ec90 0x40382a9d:0x3fc9ecb0 0x40377011:0x3fc9ecd0 0x4037b2f9:0x3fc9ecf0 0x4037b32b:0x3fc9ed20 0x4037768d:0x3fc9ed40 0x4037b965:0x3fc9ed60 0x40041a73:0x3fc9ee20 0x40049059:0x3fc9ee40 0x40048818:0x3fc9ee60 0x40048c4b:0x3fc9ee80 0x40043cf3:0x3fc9eea0 0x40043cd5:0x3fc9eec0 0x40044183:0x3fc9eee0 0x40044281:0x3fc9ef60 0x40376715:0x3fc9efb0 0x4037664b:0x3fc9efd0 0x4037b819:0x3fc9eff0 0x4037f347:0x3fcb80e0 0x4216eb41:0x3fcb8100 0x4038429b:0x3fcb8120 0x40382e6d:0x3fcb8140 + + + + +ELF file SHA256: 45fa9d5b9 + +Rebooting... +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0xc (RTC_SW_CPU_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x4038236c +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (468) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (494) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (543) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1303) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1339) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1376) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1440) MAIN: Matter node created successfully +W (1441) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1462) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1471) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1506) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1541) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1550) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1576) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1585) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1597) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0 +I (1620) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1621) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1643) wifi:Init static tx FG buffer num: 2 +I (1647) wifi:Init static rx buffer size: 1600 +I (1651) wifi:Init static rx buffer num: 10 +I (1655) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1662) wifi_init: accept mbox: 6 +I (1665) wifi_init: tcpip mbox: 32 +I (1668) wifi_init: udp mbox: 6 +I (1671) wifi_init: tcp mbox: 6 +I (1674) wifi_init: tcp tx win: 5760 +I (1677) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1691) chip[DL]: NVS set: chip-counters/reboot-count = 15842 (0x3DE2) +I (1694) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1697) BLE_INIT: BT controller compile version [1bb2f50] +I (1702) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1715) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +E BOD: Brownout detector was triggered + + +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x3 (RTC_SW_SYS_RST),boot:0xb (SPI_FAST_FLASH_BOOT) +Saved PC:0x40376756 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x14f0 +load:0x403c8700,len:0xd24 +load:0x403cb700,len:0x2f48 +entry 0x403c8924 +I (24) boot: ESP-IDF v5.5.3-dirty 2nd stage bootloader +I (24) boot: compile time Jun 29 2026 11:03:24 +I (24) boot: Multicore bootloader +I (25) boot: chip revision: v0.2 +I (27) boot: efuse block revision: v1.4 +I (31) boot.esp32s3: Boot SPI Speed : 80MHz +I (35) boot.esp32s3: SPI Mode : DIO +I (39) boot.esp32s3: SPI Flash Size : 8MB +I (42) boot: Enabling RNG early entropy source... +I (47) boot: Partition Table: +I (50) boot: ## Label Usage Type ST Offset Length +I (56) boot: 0 esp_secure_cert unknown 3f 06 0000d000 00002000 +I (62) boot: 1 nvs WiFi data 01 02 00010000 00020000 +I (69) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000 +I (75) boot: 3 otadata OTA data 01 00 00031000 00002000 +I (82) boot: 4 phy_init RF data 01 01 00033000 00001000 +I (88) boot: 5 ota_0 OTA app 00 10 00040000 00300000 +I (95) boot: 6 ota_1 OTA app 00 11 00340000 00300000 +I (101) boot: 7 fctry WiFi data 01 02 00640000 00006000 +I (108) boot: End of partition table +I (112) esp_image: segment 0: paddr=00040020 vaddr=3c170020 size=63194h (405908) map +I (190) esp_image: segment 1: paddr=000a31bc vaddr=3fc9c600 size=05cc0h ( 23744) load +I (196) esp_image: segment 2: paddr=000a8e84 vaddr=40374000 size=07194h ( 29076) load +I (202) esp_image: segment 3: paddr=000b0020 vaddr=42000020 size=16ff80h (1507200) map +I (469) esp_image: segment 4: paddr=0021ffa8 vaddr=4037b194 size=113a0h ( 70560) load +I (484) esp_image: segment 5: paddr=00231350 vaddr=50000000 size=00020h ( 32) load +I (494) boot: Loaded app from partition at offset 0x40000 +I (495) boot: Disabling RNG early entropy source... +I (505) octal_psram: vendor id : 0x0d (AP) +I (505) octal_psram: dev id : 0x02 (generation 3) +I (505) octal_psram: density : 0x03 (64 Mbit) +I (507) octal_psram: good-die : 0x01 (Pass) +I (511) octal_psram: Latency : 0x01 (Fixed) +I (516) octal_psram: VCC : 0x01 (3V) +I (520) octal_psram: SRF : 0x01 (Fast Refresh) +I (525) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (530) octal_psram: BurstLen : 0x01 (32 Byte) +I (534) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (539) octal_psram: DriveStrength: 0x00 (1/1) +I (544) esp_psram: Found 8MB PSRAM device +I (547) esp_psram: Speed: 40MHz +I (550) cpu_start: Multicore app +I (1284) esp_psram: SPI SRAM memory test OK +I (1292) cpu_start: GPIO 44 and 43 are used as console UART I/O pins +I (1293) cpu_start: Pro cpu start user code +I (1293) cpu_start: cpu freq: 160000000 Hz +I (1295) app_init: Application information: +I (1299) app_init: Project name: kota-sensorboard +I (1304) app_init: App version: 1.0 +I (1307) app_init: Compile time: Jun 29 2026 11:03:32 +I (1312) app_init: ELF file SHA256: 45fa9d5b9... +I (1317) app_init: ESP-IDF: v5.5.3-dirty +I (1321) efuse_init: Min chip rev: v0.0 +I (1325) efuse_init: Max chip rev: v0.99 +I (1329) efuse_init: Chip rev: v0.2 +I (1333) heap_init: Initializing. RAM available for dynamic allocation: +I (1340) heap_init: At 3FCB3FD0 len 00035740 (213 KiB): RAM +I (1345) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (1350) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (1355) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM +I (1361) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (1368) spi_flash: detected chip: generic +I (1371) spi_flash: flash io: dio +I (1377) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (1381) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (1396) main_task: Started on CPU0 +I (1399) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations +I (1407) main_task: Calling app_main() +E (1410) ID: Device MAC address: A4:CB:8F:C9:46:F8 +I (1435) OTA_CLIENT: No OTA token found in NVS, need to register device +I (1439) MAIN: Matter node created successfully +W (1441) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1444) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1453) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1462) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1471) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1479) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1488) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1497) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1506) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1514) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1523) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1532) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1541) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again. +W (1549) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again. +W (1558) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again. +W (1567) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again. +W (1576) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again. +W (1585) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again. +I (1595) pp: pp rom version: e7ae62f +I (1597) net80211: net80211 rom version: e7ae62f +I (1602) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0 +I (1619) wifi:wifi firmware version: 4df78f2 +I (1620) wifi:wifi certification version: v7.0 +I (1620) wifi:config NVS flash: enabled +I (1621) wifi:config nano formatting: disabled +I (1625) wifi:Init data frame dynamic rx buffer num: 32 +I (1630) wifi:Init static rx mgmt buffer num: 5 +I (1634) wifi:Init management short buffer num: 32 +I (1638) wifi:Init dynamic tx buffer num: 32 +I (1642) wifi:Init static tx FG buffer num: 2 +I (1647) wifi:Init static rx buffer size: 1600 +I (1651) wifi:Init static rx buffer num: 10 +I (1655) wifi:Init dynamic rx buffer num: 32 +I (1659) wifi_init: rx ba win: 6 +I (1661) wifi_init: accept mbox: 6 +I (1665) wifi_init: tcpip mbox: 32 +I (1668) wifi_init: udp mbox: 6 +I (1671) wifi_init: tcp mbox: 6 +I (1673) wifi_init: tcp tx win: 5760 +I (1677) wifi_init: tcp rx win: 5760 +I (1680) wifi_init: tcp mss: 1440 +I (1692) chip[DL]: NVS set: chip-counters/reboot-count = 15843 (0x3DE3) +I (1695) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC) +I (1697) BLE_INIT: BT controller compile version [1bb2f50] +I (1702) BLE_INIT: Using main XTAL as clock source +I (1706) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1 +I (1715) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa +I (1720) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10 +I (1767) phy_init: Saving new calibration data due to checksum failure or outdated calibration data, mode(0) +I (1784) NimBLE: GAP procedure initiated: stop advertising. + +I (1786) CHIP[DL]: BLE host-controller synced +I (2286) chip[DL]: Starting ESP WiFi layer +I (2288) wifi:mode : sta (a4:cb:8f:c9:46:f8) +I (2289) wifi:enable tsf +W (2289) wifi:Haven't to connect to a suitable AP now! +I (2290) chip[DL]: +I (2290) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43 +Done driving station state, nothing else to do... +I (2299) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2 +W (2303) wifi:Haven't to connect to a suitable AP now! +I (2314) chip[DL]: Done driving station state, nothing else to do... +I (2320) chip[SVR]: Initializing subscription resumption storage... +I (2327) chip[SVR]: Server initializing... +I (2330) chip[TS]: Last Known Good Time: 2026-08-04T05:56:06 +I (2336) chip[DMG]: AccessControl: initializing +I (2339) chip[DMG]: Examples::AccessControlDelegate::Init +I (2344) chip[DMG]: AccessControl: setting +I (2348) chip[DMG]: DefaultAclStorage: initializing +I (2353) chip[DMG]: DefaultAclStorage: 0 entries loaded +I (2358) chip[SVR]: WARNING: mTestEventTriggerDelegate is null +I (2377) chip[ZCL]: Using ZAP configuration... +I (2378) esp_matter_cluster: Cluster plugin init common callback +I (2378) chip[DMG]: AccessControlCluster: initializing +I (2381) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported +I (2393) chip[DIS]: Updating services using commissioning mode 1 +I (2395) chip[DIS]: CHIP minimal mDNS started advertising. +I (2400) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0 +I (2410) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: 73BA585012FF9068. +I (2420) chip[DIS]: mDNS service published: _matterc._udp +I (2425) chip[IN]: CASE Server enabling CASE session setups +I (2431) chip[SVR]: Joining Multicast groups +I (2434) chip[SVR]: Server Listening... +I (2439) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 ********** +I (2449) data_model: Dynamic endpoint 0 added +E (2452) data_model: Cluster cannot be NULL. +I (2457) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 ********** +I (2467) data_model: Dynamic endpoint 1 added +E (2471) data_model: Cluster cannot be NULL. +I (2476) chip[DL]: WIFI_EVENT_STA_START +I (2477) MAIN: ESP-Matter started successfully +W (2479) wifi:Haven't to connect to a suitable AP now! +I (2488) chip[DL]: Done driving station state, nothing else to do... +I (2495) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable) +I (2505) NimBLE: GAP procedure initiated: advertise; +I (2506) NimBLE: disc_mode=2 + +I (2506) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40 +I (2511) NimBLE: + +I (2512) chip[DL]: CHIPoBLE advertising started +I (2513) MAIN: Commissioning window opened +> +I (4060) I2C: Creating I2C mutex +I (14061) SPI: Creating SPI mutex +I (14103) INFLUX_BUFFER: SD card mounted successfully +I (14104) INFLUX_BUFFER: /sdcard exists, SD card initialized successfully +I (14107) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +W (14121) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota +I (14145) BME688: Delaying for 10000 microseconds +I (14763) Flicker: Flicker sensor initialized on pin 14 +I (14765) temperature_sensor: Range [20°C ~ 100°C], error < 2°C +I (14767) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data +I (14767) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data +W (14772) wifi:Haven't to connect to a suitable AP now! +I (14768) SEN66: Creating sensor mutex +W (14782) AS7341: Reading spectral data from AS7341 +I (14775) INFLUX_BUFFER: Flushing chunk starting at line 10: +I (14796) INFLUX: Writing to InfluxDB +I (14784) SEN66: Waking up sensor by setting power GPIO to enabled level... +I (14783) DeviceStatus: Active tasks: 26 +I (14813) DeviceStatus: Task list: +gather_sensor_t X 1 1344 22 +IDLE1 R 0 812 6 +IDLE0 R 0 692 5 +gather_sensor_t B 1 2552 21 +ds1307_update B 1 800 17 +gather_task B 1 1964 19 +gather_task B 1 14032 20 +alive_diode_tas B 5 352 8 +reset_task B 0 3432 15 +tiT B 18 2420 10 +gather_sensor_t B 1 1720 24 +ota_task B 1 7516 18 +CHIP B 1 3700 12 +Tmr Svc B 1 1364 7 +gather_sensor_t B 1 1092 25 +sys_evt B 20 2684 9 +ipc0 S 24 372 1 +ipc1 S 24 524 2 +console B 5 648 16 +esp_timer S 22 3332 3 +gather_sensor_t B 1 2332 23 +gather_sensor_t B 1 2544 26 +wifi B 23 4324 11 +btController B 23 1960 13 +main B 1 876 4 +nimble_host B 21 1480 14 + +W (14878) DeviceStatus: RTC is uninitialized, using current time as timestamp +W (15105) AS7341: Reading basic counts data from AS7341 +W (15121) AS7341: Reading flicker detection state from AS7341 +W (15339) as7341: FD Status Register: 0x20 (0b00100000) +I (15345) AS7341: Updating Matter attributes with AS7341 data: 16707 +I (15345) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms +I (15359) Flicker: Starting to collect samples in gather task +I (15355) BME688: Delaying for 199517 microseconds +I (15347) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 ********** +I (15374) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 16707 ********** +E (15386) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +E (15398) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0 +W (15400) AS7341: Formatting AS7341 data in line protocol +AS7341 line protocol: +as7341_adc,device=A4CB8FC946F8 f1=1,f2=2,f3=2,f4=3,f5=3,f6=4,f7=7,f8=9,clear=21,nir=5 946684801000000000 +as7341_basic_counts,device=A4CB8FC946F8 f1=2.23,f2=4.46,f3=4.46,f4=6.69,f5=6.69,f6=8.92,f7=15.62,f8=20.08,clear=46.85,nir=11.15 946684801000000000 +as7341_flicker,device=A4CB8FC946F8 state="unknown" 946684801000000000 +W (15890) DeviceStatus: RTC is uninitialized, using current time as timestamp +I (16383) Flicker: Finished collecting samples, notifying main task +I (16385) Flicker: Collected 1024 samples for dominant frequency analysis +I (16392) SENSOR_GATHERER: Sensor should now be asleep +I (16404) SENSOR_GATHERER: Sensor should now be asleep +I (16444) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null ********** +I (16446) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1018 ********** +E (16459) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +E (16471) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0 +W (16473) BME688: RTC is uninitialized, using current time as timestamp +I (16891) SENSOR_GATHERER: Sensor should now be asleep +W (17484) BME688: RTC is uninitialized, using current time as timestamp +--- disconnected --- \ No newline at end of file diff --git a/tests/fixtures/synthetic_fw1.0.16_healthy.txt b/tests/fixtures/synthetic_fw1.0.16_healthy.txt new file mode 100644 index 0000000..6dd873b --- /dev/null +++ b/tests/fixtures/synthetic_fw1.0.16_healthy.txt @@ -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 diff --git a/tests/test_engine.py b/tests/test_engine.py index 4a668ca..d447bfd 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -43,16 +43,20 @@ APP = ["I (1299) app_init: Project name: kota-sensorboard", # ------------------------------------------------------------------ rules def test_rules_load_and_have_no_cycles(): - rules = load_rules() - by_id = {r.id: r for r in rules} - for r in rules: - seen, todo = set(), list(r.explained_by) + from kota_tech.diag import kota + graph = {r.id: r.explained_by for r in load_rules()} + for st in ("ERROR", "OUT_OF_RESOURCES", "CONNECTION_ERROR", "RTC_ERROR", "SD_ERROR", "RESETING", "HTTP_SERVER"): + 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: 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: seen.add(rid) - todo += by_id[rid].explained_by + todo += graph.get(rid, ()) # --------------------------------------------------------- real captures @@ -233,3 +237,13 @@ def test_session_log_format(tmp_path): for ln in lines: e.feed(ln) 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") diff --git a/tests/test_flash.py b/tests/test_flash.py index ce595b0..43191d5 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -1,5 +1,6 @@ -"""Flashing: image validation on the real official image, plan building, -and the runner against a fake esptool (no hardware).""" +"""Flashing: image validation on the real image in firmware/, the folder +catalogue (compile-date order), plan building, and the runner against a fake +esptool (no hardware).""" import hashlib import sys @@ -8,31 +9,39 @@ from pathlib import Path 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.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") def image(): - imgs = official_images() - assert len(imgs) == 1 + imgs, problems = folder_images() + assert imgs and problems == [] return imgs[0] # ------------------------------------------------------------------ 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.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.partition("ota_0").offset == 0x40000 assert image.partition("otadata").offset == 0x31000 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): app = image.app_bytes() 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} -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", [ ((0, 0x00), "not an ESP32 firmware"), ((12, 0x05), "not ESP32-S3"), @@ -64,6 +62,93 @@ def test_wrong_images_are_rejected(tmp_path, patch, msg): 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 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) assert not res.ok and res.failed_step == 0 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" diff --git a/tests/test_kota.py b/tests/test_kota.py new file mode 100644 index 0000000..7da73e7 --- /dev/null +++ b/tests/test_kota.py @@ -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()) diff --git a/tests/test_service.py b/tests/test_service.py index 31449f3..cfad61f 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -33,7 +33,7 @@ def test_bench_box_is_a_connectivity_problem(): assert service_status(e.report()) == CONNECTIVITY note = service_note(e.report()) 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 @@ -89,3 +89,19 @@ def test_invalid_mac_rejected(bad): def test_normalize_mac(): 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 diff --git a/uv.lock b/uv.lock index f89a414..491d840 100644 --- a/uv.lock +++ b/uv.lock @@ -250,7 +250,7 @@ wheels = [ [[package]] name = "kota-technician-tool" -version = "0.6.0" +version = "0.7.0" source = { virtual = "." } dependencies = [ { name = "esptool" },