Technician tool v0.2
Imported from the Google Drive folder 004-technician_tool/v0.2.
This commit is contained in:
1 parent
e5a33a1c34
commit
e8b72b743d
17 files changed
+2180
-53
No files matched your search
@@ -1,3 +1,3 @@
|
||||
"""KOTA sensorbox technician tool."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Load saved logs for offline analysis.
|
||||
|
||||
Accepts both raw serial captures and this tool's session logs, whose lines
|
||||
look like "[14:02:11.532] I (1234) TAG: msg" and whose host events look
|
||||
like "[14:02:11.532] --- reset issued by tool ---".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .logline import LineKind, LogLine, parse_line, split_glued
|
||||
|
||||
_SESSION_PREFIX = re.compile(r"^\[(\d\d):(\d\d):(\d\d)\.(\d{3})\] ")
|
||||
_HOST_EVENT = re.compile(r"^--- .* ---$")
|
||||
|
||||
|
||||
def read_log_file(path: Path) -> list[LogLine]:
|
||||
text = path.read_bytes().decode("utf-8", errors="replace")
|
||||
out: list[LogLine] = []
|
||||
for i, raw in enumerate(text.splitlines()):
|
||||
host_time = i * 0.001 # raw capture: keep order, no wall clock
|
||||
if m := _SESSION_PREFIX.match(raw):
|
||||
h, mi, s, ms = (int(g) for g in m.groups())
|
||||
host_time = h * 3600 + mi * 60 + s + ms / 1000
|
||||
raw = raw[m.end():]
|
||||
if _HOST_EVENT.match(raw):
|
||||
out.append(LogLine(raw, LineKind.HOST, host_time))
|
||||
continue
|
||||
out.extend(parse_line(part, host_time) for part in split_glued(raw))
|
||||
return out
|
||||
+36
-30
@@ -76,65 +76,71 @@ class LineFramer:
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
# Stale FIFO bytes arrive immediately after open; anything later is live.
|
||||
STALE_WINDOW_S = 0.25
|
||||
|
||||
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
|
||||
drop_first_partial: bool = False, now: float | None = None):
|
||||
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
|
||||
# The S3 USB-Serial-JTAG FIFO keeps the tail of an old line while no
|
||||
# host is reading and flushes it on open; drop bytes up to the first
|
||||
# newline after a (re)connect so it is not glued to a fresh line.
|
||||
# Only bytes arriving within STALE_WINDOW_S of open are considered
|
||||
# stale, otherwise an idle board's next boot banner would be lost.
|
||||
self._dropping = drop_first_partial
|
||||
self._drop_deadline = (time.monotonic() if now is None else now) + self.STALE_WINDOW_S
|
||||
self._first_line_pending = drop_first_partial
|
||||
|
||||
def cancel_drop(self) -> None:
|
||||
self._dropping = False
|
||||
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 self._dropping and now > self._drop_deadline:
|
||||
self._dropping = False
|
||||
if data:
|
||||
self._buf.extend(data)
|
||||
self._last_rx = now
|
||||
if self._dropping:
|
||||
i = self._buf.find(b"\n")
|
||||
if i < 0:
|
||||
return []
|
||||
del self._buf[: i + 1]
|
||||
self._dropping = False
|
||||
out: list[str] = []
|
||||
while (i := self._buf.find(b"\n")) >= 0:
|
||||
out.append(self._decode(self._buf[:i]))
|
||||
out += self._emit(self._buf[:i])
|
||||
del self._buf[: i + 1]
|
||||
if len(self._buf) >= self.max_line:
|
||||
out.append(self._decode(self._buf))
|
||||
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:
|
||||
line = self._decode(self._buf)
|
||||
self._buf.clear()
|
||||
return [line]
|
||||
return self.flush()
|
||||
return []
|
||||
|
||||
def flush(self) -> list[str]:
|
||||
if not self._buf:
|
||||
return []
|
||||
line = self._decode(self._buf)
|
||||
out = self._emit(self._buf)
|
||||
self._buf.clear()
|
||||
return [line]
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _decode(b: bytes | bytearray) -> str:
|
||||
return bytes(b).decode("utf-8", errors="replace")
|
||||
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]
|
||||
@@ -33,6 +33,13 @@ class SessionLog:
|
||||
self._fh.write(f"[{ts}] {line.text}\n")
|
||||
self._fh.flush()
|
||||
|
||||
def write_report(self, text: str) -> None:
|
||||
"""Append the diagnosis at the end of the session (for the firmware team)."""
|
||||
if self._fh is None:
|
||||
return
|
||||
self._fh.write("\n===== Diagnosis at end of session =====\n" + text + "\n")
|
||||
self._fh.flush()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._fh is not None:
|
||||
self._fh.close()
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,554 @@
|
||||
"""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")
|
||||
|
||||
|
||||
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),
|
||||
("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
|
||||
|
||||
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 ""))
|
||||
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
|
||||
|
||||
@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)
|
||||
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)
|
||||
|
||||
# ------------------------------------------------------------ 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())
|
||||
@@ -0,0 +1,91 @@
|
||||
"""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,
|
||||
),
|
||||
}
|
||||
|
||||
_ELF = re.compile(r"app_init: ELF file SHA256:\s+(?P<sha>[0-9a-f]{6,})")
|
||||
_VER = re.compile(r"app_init: App version:\s+(?P<v>\S+)")
|
||||
_BUILT = re.compile(r"app_init: Compile time:\s+(?P<t>.+)$")
|
||||
_IDF = re.compile(r"app_init: ESP-IDF:\s+(?P<v>\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"
|
||||
@@ -0,0 +1,565 @@
|
||||
# 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, "<category>.<name>"
|
||||
# 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 = "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<host>[\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<ssid>[^"]+)"'
|
||||
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: (?P<code>401|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: (?P<code>404)'
|
||||
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: (?P<code>5\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 (?P<key>INFLUX_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<err>[^)]+)\)'
|
||||
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<err>\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<err>\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 (?P<sensor>BME688|SEN66|AS7341|Microphone|Flicker sensor|DeviceStatus): (?P<err>.+)'
|
||||
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 = '(?P<sensor>BME688|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<code>\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<err>\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<reason>.+)'
|
||||
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 firmware reads the microphone enable line (GPIO3) as 'off', which it treats as disabled on purpose. Noise levels are not recorded."
|
||||
actions = ["If this box should measure noise: check the microphone enable switch/jumper and its wiring (inside the case)."]
|
||||
|
||||
[[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"]
|
||||
@@ -0,0 +1,230 @@
|
||||
"""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,
|
||||
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)
|
||||
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 " - <i>check it matches the box</i>"
|
||||
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
|
||||
self.led_text.setText(f"<b>{name}</b>{ask}{reason}")
|
||||
|
||||
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())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
|
||||
|
||||
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("<p style='color:#666'>Nothing to show yet.</p>")
|
||||
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"<li>{_esc(a)}</li>" 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 = "<br>".join(_esc(e) for e in f.evidence)
|
||||
return (
|
||||
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
|
||||
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
|
||||
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
|
||||
+ f"<p style='color:#666'>{seen}</p>"
|
||||
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
|
||||
if evidence else "")
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QTextCharFormat, QTextCursor
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -11,6 +13,7 @@ from PySide6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
@@ -18,11 +21,14 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from .. import __version__
|
||||
from ..core.logfile import read_log_file
|
||||
from ..core.logline import LineKind, LogLine
|
||||
from ..core.session import SessionLog
|
||||
from ..diag.engine import DiagnosticsEngine
|
||||
from ..serialio.manager import SerialManager, State
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
from .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
|
||||
MAX_LINES = 20000
|
||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||
@@ -55,6 +61,8 @@ class SerialTab(QWidget):
|
||||
self._session = SessionLog()
|
||||
self._ports: list[PortInfo] = []
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine = DiagnosticsEngine()
|
||||
self._diag_dirty = True
|
||||
|
||||
# --- controls
|
||||
self.port_combo = QComboBox(minimumWidth=320)
|
||||
@@ -68,6 +76,10 @@ class SerialTab(QWidget):
|
||||
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)")
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
||||
@@ -75,7 +87,8 @@ class SerialTab(QWidget):
|
||||
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):
|
||||
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn,
|
||||
self.analyze_btn, self.copy_btn):
|
||||
row2.addWidget(w)
|
||||
row2.addStretch(1)
|
||||
|
||||
@@ -88,11 +101,19 @@ class SerialTab(QWidget):
|
||||
self.view.setFont(mono)
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
self.diag = DiagnosticsPanel()
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.addWidget(self.view)
|
||||
split.addWidget(self.diag)
|
||||
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(self.view, 1)
|
||||
layout.addWidget(split, 1)
|
||||
layout.addWidget(self.status)
|
||||
|
||||
# --- wiring
|
||||
@@ -103,6 +124,13 @@ class SerialTab(QWidget):
|
||||
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)
|
||||
|
||||
# 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)
|
||||
@@ -146,18 +174,23 @@ class SerialTab(QWidget):
|
||||
if port is None:
|
||||
return
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine.reset()
|
||||
self._diag_dirty = True
|
||||
self._session.start(port.mac or port.device)
|
||||
self._manager.connect(port)
|
||||
|
||||
def _on_state(self, state: State, detail: str) -> None:
|
||||
running = state != State.DISCONNECTED
|
||||
if not running:
|
||||
if not running and self._session.path is not None:
|
||||
self._session.write_report(self._engine.report().to_text())
|
||||
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()
|
||||
|
||||
@@ -172,6 +205,8 @@ class SerialTab(QWidget):
|
||||
# ---- lines -------------------------------------------------------
|
||||
def _on_line(self, line: LogLine) -> None:
|
||||
self._session.write(line)
|
||||
self._engine.feed(line)
|
||||
self._diag_dirty = True
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
if line.level in ("E", "W"):
|
||||
@@ -200,6 +235,36 @@ class SerialTab(QWidget):
|
||||
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())
|
||||
|
||||
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._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)")
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "KOTA sensorbox technician diagnostic tool (demo)"
|
||||
requires-python = ">=3.11,<3.14"
|
||||
dependencies = [
|
||||
|
||||
+1
-1
@@ -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.1"
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.2"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
|
||||
+1
-1
@@ -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.1"
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.2"
|
||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||
|
||||
where uv >nul 2>nul
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
W (19497) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (19820) SEN66: Sent start measurement command, waiting for sensor to be ready...
|
||||
W (20498) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (21499) BME688: RTC is uninitialized, using current time as timestamp
|
||||
E (21821) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0
|
||||
E (21823) esp-tls: Failed to open new connection
|
||||
E (21824) transport_base: Failed to open a new connection
|
||||
E (21835) HTTP_CLIENT: Connection failed, sock < 0
|
||||
I (21836) INFLUX: HTTP request performed, status code: 0
|
||||
E (21847) INFLUX: Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT
|
||||
E (21849) INFLUX_BUFFER: Failed to write chunk to Influx: ESP_FAIL
|
||||
W (22500) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (23501) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (23860) INFLUX: Writing to InfluxDB
|
||||
ESP-ROM:esp32s3-20210327
|
||||
Build:Mar 27 2021
|
||||
rst:0x15 (USB_UART_CHIP_RESET),boot:0x2b (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 (36) 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 (70) boot: 2 nvs_keys NVS keys 01 04 00030000 00001000
|
||||
I (76) boot: 3 otadata OTA data 01 00 00031000 00002000
|
||||
I (83) boot: 4 phy_init RF data 01 01 00033000 00001000
|
||||
I (89) boot: 5 ota_0 OTA app 00 10 00040000 00300000
|
||||
I (96) 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 (506) octal_psram: vendor id : 0x0d (AP)
|
||||
I (506) 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 (1294) cpu_start: Pro cpu start user code
|
||||
I (1294) cpu_start: cpu freq: 160000000 Hz
|
||||
I (1296) 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 (1351) 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 (1372) 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 (1459) OTA_CLIENT: No OTA token found in NVS, need to register device
|
||||
I (1463) MAIN: Matter node created successfully
|
||||
W (1464) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1468) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1476) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1485) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1494) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1503) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1512) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1520) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1529) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1538) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1547) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1555) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1564) data_model: Server Cluster 0x00000003 on endpoint 0x0001 already exists. Not creating again.
|
||||
W (1573) data_model: Attribute 0x0000FFFC on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1582) data_model: Attribute 0x0000FFFD on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1590) data_model: Attribute 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1599) data_model: Attribute 0x00000001 on cluster 0x00000003 already exists. Not creating again.
|
||||
W (1608) data_model: Command 0x00000000 on cluster 0x00000003 already exists. Not creating again.
|
||||
I (1619) pp: pp rom version: e7ae62f
|
||||
I (1620) net80211: net80211 rom version: e7ae62f
|
||||
I (1626) wifi:wifi driver task: 3fcc52d0, prio:23, stack:6656, core=0
|
||||
I (1635) wifi:wifi firmware version: 4df78f2
|
||||
I (1635) wifi:wifi certification version: v7.0
|
||||
I (1638) wifi:config NVS flash: enabled
|
||||
I (1642) wifi:config nano formatting: disabled
|
||||
I (1646) wifi:Init data frame dynamic rx buffer num: 32
|
||||
I (1651) wifi:Init static rx mgmt buffer num: 5
|
||||
I (1655) wifi:Init management short buffer num: 32
|
||||
I (1660) wifi:Init dynamic tx buffer num: 32
|
||||
I (1664) wifi:Init static tx FG buffer num: 2
|
||||
I (1668) wifi:Init static rx buffer size: 1600
|
||||
I (1672) wifi:Init static rx buffer num: 10
|
||||
I (1676) wifi:Init dynamic rx buffer num: 32
|
||||
I (1681) wifi_init: rx ba win: 6
|
||||
I (1683) wifi_init: accept mbox: 6
|
||||
I (1686) wifi_init: tcpip mbox: 32
|
||||
I (1689) wifi_init: udp mbox: 6
|
||||
I (1692) wifi_init: tcp mbox: 6
|
||||
I (1695) wifi_init: tcp tx win: 5760
|
||||
I (1698) wifi_init: tcp rx win: 5760
|
||||
I (1701) wifi_init: tcp mss: 1440
|
||||
I (1712) chip[DL]: NVS set: chip-counters/reboot-count = 11 (0xB)
|
||||
I (1715) chip[DL]: Real time clock set to 946684800 (2000-01-01 00:00:00 UTC)
|
||||
I (1718) BLE_INIT: BT controller compile version [1bb2f50]
|
||||
I (1722) BLE_INIT: Using main XTAL as clock source
|
||||
I (1727) BLE_INIT: Feature Config, ADV:1, BLE_50:1, DTM:1, SCAN:1, CCA:0, SMP:1, CONNECT:1
|
||||
I (1736) BLE_INIT: Bluetooth MAC: a4:cb:8f:c9:46:fa
|
||||
I (1741) phy_init: phy_version 711,97bcf0a2,Aug 25 2025,19:04:10
|
||||
I (1784) phy_init: Saving new calibration data due to checksum failure or outdated calibration data, mode(0)
|
||||
I (1801) NimBLE: GAP procedure initiated: stop advertising.
|
||||
|
||||
I (1802) CHIP[DL]: BLE host-controller synced
|
||||
I (2303) chip[DL]: Starting ESP WiFi layer
|
||||
I (2305) wifi:mode : sta (a4:cb:8f:c9:46:f8)
|
||||
I (2306) wifi:enable tsf
|
||||
W (2306) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2307) chip[DL]: I (2307) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 43
|
||||
Done driving station state, nothing else to do...
|
||||
I (2316) chip[DL]: Posting ESPSystemEvent: Wifi Event with eventId : 2
|
||||
W (2320) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2331) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (2337) chip[SVR]: Initializing subscription resumption storage...
|
||||
I (2344) chip[SVR]: Server initializing...
|
||||
I (2347) chip[TS]: Last Known Good Time: 2026-08-04T05:56:06
|
||||
I (2352) chip[DMG]: AccessControl: initializing
|
||||
I (2356) chip[DMG]: Examples::AccessControlDelegate::Init
|
||||
I (2361) chip[DMG]: AccessControl: setting
|
||||
I (2365) chip[DMG]: DefaultAclStorage: initializing
|
||||
I (2370) chip[DMG]: DefaultAclStorage: 0 entries loaded
|
||||
I (2375) chip[SVR]: WARNING: mTestEventTriggerDelegate is null
|
||||
I (2392) chip[ZCL]: Using ZAP configuration...
|
||||
I (2393) esp_matter_cluster: Cluster plugin init common callback
|
||||
I (2393) chip[DMG]: AccessControlCluster: initializing
|
||||
I (2396) chip[ZCL]: WRITE ERR: ep 0 clus 0x0000_0030 attr 0x0000_0000 not supported
|
||||
I (2406) chip[DIS]: Updating services using commissioning mode 1
|
||||
I (2410) chip[DIS]: CHIP minimal mDNS started advertising.
|
||||
I (2415) chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
|
||||
I (2425) chip[DIS]: CHIP minimal mDNS configured as 'Commissionable node device'; instance name: F90440ED10E7BA33.
|
||||
I (2435) chip[DIS]: mDNS service published: _matterc._udp
|
||||
I (2440) chip[IN]: CASE Server enabling CASE session setups
|
||||
I (2446) chip[SVR]: Joining Multicast groups
|
||||
I (2449) chip[SVR]: Server Listening...
|
||||
I (2454) esp_matter_attribute: ********** R : Endpoint 0x0000's Cluster 0x0000003C's Attribute 0x0000FFFC is 0 **********
|
||||
I (2464) data_model: Dynamic endpoint 0 added
|
||||
E (2467) data_model: Cluster cannot be NULL.
|
||||
I (2472) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000003's Attribute 0x00000001 is 2 **********
|
||||
I (2482) data_model: Dynamic endpoint 1 added
|
||||
E (2486) data_model: Cluster cannot be NULL.
|
||||
I (2491) chip[DL]: WIFI_EVENT_STA_START
|
||||
I (2492) MAIN: ESP-Matter started successfully
|
||||
W (2494) wifi:Haven't to connect to a suitable AP now!
|
||||
I (2503) chip[DL]: Done driving station state, nothing else to do...
|
||||
I (2510) chip[DL]: Configuring CHIPoBLE advertising (interval 25 ms, connectable)
|
||||
I (2520) NimBLE: GAP procedure initiated: advertise;
|
||||
I (2521) NimBLE: disc_mode=2
|
||||
I (2521) NimBLE: adv_channel_map=0 own_addr_type=1 adv_filter_policy=0 adv_itvl_min=40 adv_itvl_max=40
|
||||
I (2526) NimBLE:
|
||||
|
||||
I (2527) chip[DL]: CHIPoBLE advertising started
|
||||
I (2528) MAIN: Commissioning window opened
|
||||
>
|
||||
I (4074) I2C: Creating I2C mutex
|
||||
I (14075) SPI: Creating SPI mutex
|
||||
I (14117) INFLUX_BUFFER: SD card mounted successfully
|
||||
I (14118) INFLUX_BUFFER: /sdcard exists, SD card initialized successfully
|
||||
I (14121) INFLUX_BUFFER: Read INFLUX_URL from .env file: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
|
||||
W (14135) INFLUX: Initializing Influx client with URL: https://influx.kota-app.com/api/v2/write?bucket=sensorbox&org=kota
|
||||
I (14159) BME688: Delaying for 10000 microseconds
|
||||
I (14777) Flicker: Flicker sensor initialized on pin 14
|
||||
I (14779) temperature_sensor: Range [20°C ~ 100°C], error < 2°C
|
||||
I (14781) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data
|
||||
I (14781) SENSOR_GATHERER: Resumed 4 tasks to gather sensor data
|
||||
W (14786) wifi:I (14782) SEN66: Creating sensor mutex
|
||||
Haven't to connect to a suitable AP now!I (14797) SEN66: Waking up sensor by setting power GPIO to enabled level...
|
||||
|
||||
I (14788) INFLUX_BUFFER: Flushing chunk starting at line 10:
|
||||
I (14795) Flicker: Collecting 1024 samples at 1000 Hz for dominant frequency analysis for total measurement window of 1000 ms
|
||||
W (14783) MICROPHONE: Microphone is in shutdown state, skipping sample collection
|
||||
I (14823) Flicker: Starting to collect samples in gather task
|
||||
I (14811) DeviceStatus: Active tasks: 26
|
||||
I (14811) INFLUX: Writing to InfluxDB
|
||||
E (14835) SENSOR_GATHERER: Failed to get line protocol from sensor
|
||||
I (14841) DeviceStatus: Task list:
|
||||
gather_sensor_t X 1 1216 22
|
||||
main R 1 876 4
|
||||
IDLE1 R 0 700 6
|
||||
IDLE0 R 0 692 5
|
||||
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 23 1720 24
|
||||
ota_task B 1 7516 18
|
||||
CHIP B 1 3700 12
|
||||
Tmr Svc B 1 1364 7
|
||||
ds1307_update B 1 800 17
|
||||
sys_evt B 20 2684 9
|
||||
ipc0 S 24 372 1
|
||||
ipc1 S 24 524 2
|
||||
console B 5 648 16
|
||||
gather_sensor_t B 1 2332 23
|
||||
esp_timer S 22 3188 3
|
||||
gather_sensor_t B 1 2324 25
|
||||
wifi B 23 4324 11
|
||||
gather_sensor_t S 1 1344 26
|
||||
btController B 23 1960 13
|
||||
gather_sensor_t B 1 1496 21
|
||||
flicker_gather_ S 24 1948 27
|
||||
nimble_host B 21 1480 14
|
||||
|
||||
I (14853) SENSOR_GATHERER: Sensor should now be asleep
|
||||
W (14929) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
I (15861) Flicker: Finished collecting samples, notifying main task
|
||||
I (15863) Flicker: Collected 1024 samples for dominant frequency analysis
|
||||
W (15870) AS7341: Reading spectral data from AS7341
|
||||
I (15870) SENSOR_GATHERER: Sensor should now be asleep
|
||||
I (15880) BME688: Delaying for 199517 microseconds
|
||||
W (15941) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
W (16178) AS7341: Reading basic counts data from AS7341
|
||||
W (16194) AS7341: Reading flicker detection state from AS7341
|
||||
W (16412) as7341: FD Status Register: 0x20 (0b00100000)
|
||||
I (16418) AS7341: Updating Matter attributes with AS7341 data: 18108
|
||||
I (16419) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 0 **********
|
||||
I (16432) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000400's Attribute 0x00000000 is 18108 **********
|
||||
E (16445) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
|
||||
E (16447) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x400, attribute_id: 0x0
|
||||
W (16459) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
I (16460) esp_matter_attribute: ********** R : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is null **********
|
||||
I (16473) esp_matter_attribute: ********** W : Endpoint 0x0001's Cluster 0x00000403's Attribute 0x00000000 is 1013 **********
|
||||
E (16486) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
|
||||
E (16498) MAIN: Attribute update callback: endpoint_id: 1, cluster_id: 0x403, attribute_id: 0x0
|
||||
W (16499) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (16942) SENSOR_GATHERER: Sensor should now be asleep
|
||||
I (17283) SENSOR_GATHERER: Gathered line protocol:
|
||||
device_status,device=A4CB8FC946F8 battery=4.04,last_restart=13421,status="OK",temp=35.40,rssi=0,wifi_status="disconnected",free_heap=54927,psram_free=8305944,heap_blocks=253768,psram_blocks=79976,active_tasks=26 946684801000000000
|
||||
|
||||
W (17461) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (17511) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (18462) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (18512) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (19463) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (19513) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (19809) SEN66: Sent start measurement command, waiting for sensor to be ready...
|
||||
W (20464) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (20514) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (21465) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (21515) BME688: RTC is uninitialized, using current time as timestamp
|
||||
E (21843) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0
|
||||
E (21845) esp-tls: Failed to open new connection
|
||||
E (21846) transport_base: Failed to open a new connection
|
||||
E (21857) HTTP_CLIENT: Connection failed, sock < 0
|
||||
I (21858) INFLUX: HTTP request performed, status code: 0
|
||||
E (21869) INFLUX: Failed to perform HTTP request: 0, ESP_ERR_HTTP_CONNECT
|
||||
E (21871) INFLUX_BUFFER: Failed to write chunk to Influx: ESP_FAIL
|
||||
W (22466) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (22516) BME688: RTC is uninitialized, using current time as timestamp
|
||||
W (23467) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (23517) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (23882) INFLUX: Writing to InfluxDB
|
||||
W (24468) AS7341: RTC is uninitialized, using current time as timestamp
|
||||
W (24518) BME688: RTC is uninitialized, using current time as timestamp
|
||||
I (24781) OTA_CLIENT: Device is not registered, attempting to register...
|
||||
I (24781) SENSOR_GATHERER: Resumed 2 tasks to gather sensor data
|
||||
W (24785) wifi:W (24781) MICROPHONE: Microphone is in shutdown state, skipping sample collection
|
||||
Haven't to connect to a suitable AP now!
|
||||
E (24798) SENSOR_GATHERER: Failed to get line protocol from sensor
|
||||
I (24784) OTA_CLIENT: HTTP client handle: 0x3fcf1b34
|
||||
I (24799) DeviceStatus: Active tasks: 26
|
||||
I (24815) DeviceStatus: Task list:
|
||||
gather_sensor_t R 1 1496 21
|
||||
gather_sensor_t X 1 1136 22
|
||||
IDLE1 R 0 700 6
|
||||
IDLE0 R 0 692 5
|
||||
gather_task B 1 1756 19
|
||||
gather_task B 1 14032 20
|
||||
ds1307_update B 1 800 17
|
||||
alive_diode_tas B 5 352 8
|
||||
gather_sensor_t B 1 532 25
|
||||
gather_sensor_t B 1 748 23
|
||||
reset_task B 0 3432 15
|
||||
CHIP B 1 3700 12
|
||||
gather_sensor_t B 23 1720 24
|
||||
Tmr Svc B 1 1364 7
|
||||
tiT B 18 2420 10
|
||||
console B 5 648 16
|
||||
ipc0 S 1 372 1
|
||||
gather_sensor_t S 1 1344 26
|
||||
sys_evt B 20 2684 9
|
||||
main B 1 876 4
|
||||
esp_timer S 22 3188 3
|
||||
btController B 23 1960 13
|
||||
wifi B 23 4324 11
|
||||
ota_task B 1 5356 18
|
||||
nimble_host B 21 1480 14
|
||||
ipc1 S 24 524 2
|
||||
|
||||
W (24890) DeviceStatus: RTC is uninitialized, using current time as timestamp
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Diagnostics engine: real captures (fixtures) + synthetic scenarios built
|
||||
from the firmware's exact log strings for failures not captured yet."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
from kota_tech.core.logline import LineKind, LogLine, parse_line
|
||||
from kota_tech.diag.engine import DiagnosticsEngine, Severity, load_rules
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def run(lines, engine=None):
|
||||
e = engine or DiagnosticsEngine()
|
||||
t = 0.0
|
||||
for item in lines:
|
||||
t += 0.01
|
||||
if isinstance(item, LogLine):
|
||||
e.feed(item)
|
||||
else:
|
||||
e.feed(parse_line(item, host_time=t))
|
||||
return e, e.report()
|
||||
|
||||
|
||||
def replay(name):
|
||||
e = DiagnosticsEngine()
|
||||
for ln in read_log_file(FIX / name):
|
||||
e.feed(ln)
|
||||
return e.report()
|
||||
|
||||
|
||||
def roots(report):
|
||||
return {rc.finding.rule_id: rc for rc in report.roots}
|
||||
|
||||
|
||||
BOOT = ["ESP-ROM:esp32s3-20210327"]
|
||||
APP = ["I (1299) app_init: Project name: kota-sensorboard",
|
||||
"I (1317) app_init: ELF file SHA256: 45fa9d5b9..."]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 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)
|
||||
while todo:
|
||||
rid = todo.pop()
|
||||
assert rid != r.id, f"explained_by cycle through {r.id}"
|
||||
if rid not in seen:
|
||||
seen.add(rid)
|
||||
todo += by_id[rid].explained_by
|
||||
|
||||
|
||||
# --------------------------------------------------------- real captures
|
||||
|
||||
def test_live_bench_box_uncommissioned():
|
||||
r = replay("live_boot_A4CB8FC946F8_fw1.0.txt")
|
||||
rs = roots(r)
|
||||
assert r.firmware.known and "new_system.bin" in r.firmware.describe()
|
||||
assert r.firmware.supports_sd_wifi is False
|
||||
assert r.led == "PURPLE" # confirmed on the bench
|
||||
assert "Wi-Fi" in r.verdict and r.verdict.startswith("Problem")
|
||||
grouped = {c.rule_id for c in rs["matter.not_commissioned"].consequences}
|
||||
assert {"net.dns_fail", "influx.buffer_fail", "ota.registration_failed"} <= grouped
|
||||
assert "rtc.clock_not_running" in rs
|
||||
assert "mic.disabled" in rs
|
||||
reached = {m for m, _, t in r.milestones if t is not None}
|
||||
assert {"rom_boot", "app_started", "sd_mounted", "env_loaded", "sensors_ready"} <= reached
|
||||
assert "wifi_connected" not in reached
|
||||
|
||||
|
||||
def test_live_bench_box_led_still_green_at_30s():
|
||||
# Purple only starts when the first Influx flush fails (~60 s uptime).
|
||||
assert replay("live_reset_A4CB8FC946F8_fw1.0_restored.txt").led == "GREEN"
|
||||
|
||||
|
||||
def test_connected_box_is_healthy():
|
||||
r = replay("serial_log_20260917_162922.txt")
|
||||
assert r.verdict_severity is None
|
||||
assert "time_synced" in {m for m, _, t in r.milestones if t is not None}
|
||||
assert r.mid_run
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["serial_log_20260917_162714.txt", "kota_serial_log_20260917_163439.txt"])
|
||||
def test_offline_sample_boxes(name):
|
||||
rs = roots(replay(name))
|
||||
assert "net.dns_fail" in rs and "rtc.clock_not_running" in rs
|
||||
|
||||
|
||||
# ---------------------------------------------------- synthetic scenarios
|
||||
|
||||
def _rtc_fail_boot(reset="RTC_SW_CPU_RST"):
|
||||
return BOOT + [f"rst:0xc ({reset}),boot:0x8 (SPI_FAST_FLASH_BOOT)"] + APP + [
|
||||
"I (2493) MAIN: ESP-Matter started successfully",
|
||||
"E (3510) DS1307: Failed to initialize DS1307 control register: ESP_ERR_TIMEOUT",
|
||||
"E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT",
|
||||
"ESP_ERROR_CHECK failed: esp_err_t 0x107 (ESP_ERR_TIMEOUT) at 0x4200f7b2",
|
||||
'file: "./main/main.cpp" line 473',
|
||||
"abort() was called at PC 0x40379b1f on core 0",
|
||||
]
|
||||
|
||||
|
||||
def test_rtc_i2c_reboot_loop():
|
||||
_, r = run(_rtc_fail_boot("POWERON") + _rtc_fail_boot() + _rtc_fail_boot() + _rtc_fail_boot())
|
||||
rs = roots(r)
|
||||
root = rs["rtc.init_failed"]
|
||||
assert root.finding.severity == Severity.CRITICAL
|
||||
assert "I2C" in root.finding.meaning
|
||||
grouped = {c.rule_id for c in root.consequences}
|
||||
assert {"sys.crash", "reboot.loop"} <= grouped
|
||||
assert r.led == "BLUE"
|
||||
assert r.verdict.startswith("Problem")
|
||||
|
||||
|
||||
def test_sd_card_missing():
|
||||
_, r = run(BOOT + APP + ["E (1519) SD_CARD: Failed to initialize the card (ESP_ERR_TIMEOUT). "
|
||||
"Make sure SD card lines have pull-up resistors in place."])
|
||||
f = roots(r)["sd.card_fail"].finding
|
||||
assert "ESP_ERR_TIMEOUT" in f.title and f.severity == Severity.CRITICAL
|
||||
assert r.led == "WHITE"
|
||||
|
||||
|
||||
def test_influx_token_rejected():
|
||||
_, r = run(BOOT + APP + ["E (30000) INFLUX: Failed to perform HTTP request: 401, ESP_OK"])
|
||||
f = roots(r)["influx.auth"].finding
|
||||
assert "401" in f.title and f.severity == Severity.CRITICAL
|
||||
|
||||
|
||||
def test_recovery_resolves_network_findings():
|
||||
lines = BOOT + APP + [
|
||||
"E (21837) esp-tls: couldn't get hostname for :influx.kota-app.com: getaddrinfo() returns 202, addrinfo=0x0",
|
||||
"I (30222) SNTP: Time synchronized: Thu Sep 17 14:29:22 2026",
|
||||
"I (31000) INFLUX: HTTP request performed, status code: 204",
|
||||
]
|
||||
_, r = run(lines)
|
||||
assert "net.dns_fail" not in roots(r)
|
||||
assert any(f.rule_id == "net.dns_fail" for f in r.resolved)
|
||||
assert r.verdict.startswith("Healthy")
|
||||
|
||||
|
||||
def test_led_conditional_green_after_purple():
|
||||
e, r = run(BOOT + APP + ["E (60009) INFLUX_BUFFER: Failed to write chunk with 5 retries to Influx: ESP_FAIL"])
|
||||
assert r.led == "PURPLE"
|
||||
_, r = run(["I (61000) OTA_CLIENT: No update available"], e)
|
||||
assert r.led == "GREEN"
|
||||
_, r = run(["I (62000) OTA_CLIENT: New update available, starting OTA update..."], e)
|
||||
assert r.led == "YELLOW"
|
||||
|
||||
|
||||
def test_status_watchdog_restart_grouped_under_network_cause():
|
||||
lines = BOOT + APP + [
|
||||
"I (2542) MAIN: Commissioning window opened",
|
||||
"E (60009) INFLUX_BUFFER: Failed to write chunk with 5 retries to Influx: ESP_FAIL",
|
||||
"W (200000) BME688: x",
|
||||
] + BOOT + ["rst:0x3 (RTC_SW_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)"] + APP + [
|
||||
"I (2542) MAIN: Commissioning window opened",
|
||||
"E (60009) INFLUX_BUFFER: Failed to write chunk with 5 retries to Influx: ESP_FAIL",
|
||||
]
|
||||
_, r = run(lines)
|
||||
grouped = {c.rule_id for c in roots(r)["matter.not_commissioned"].consequences}
|
||||
assert "reboot.status_watchdog" in grouped
|
||||
assert "reboot.loop" not in grouped # 200 s boots are not a loop
|
||||
|
||||
|
||||
def test_tool_reset_is_not_a_finding():
|
||||
lines = BOOT + APP + [LogLine("--- reset issued by tool ---", LineKind.HOST, 0.5)]
|
||||
lines += BOOT + ["rst:0x15 (USB_UART_CHIP_RESET),boot:0x2b (SPI_FAST_FLASH_BOOT)"] + APP
|
||||
_, r = run(lines)
|
||||
assert not [rc for rc in r.roots if rc.finding.rule_id.startswith("reboot.")]
|
||||
assert len(r.boots) == 3 # mid-run record + 2 boots
|
||||
|
||||
|
||||
def test_first_seen_is_per_boot_and_own_severity_breaks_ties():
|
||||
rtc = "W ({}) BME688: RTC is uninitialized, using current time as timestamp"
|
||||
lines = [rtc.format(200_000 + i) for i in range(5)] # before the reset
|
||||
lines += BOOT + APP + [rtc.format(15_000 + i) for i in range(5)]
|
||||
lines += ["W (24756) MICROPHONE: Microphone is in shutdown state, skipping sample collection",
|
||||
"E (24773) SENSOR_GATHERER: Failed to get line protocol from sensor"] * 2
|
||||
_, r = run(lines)
|
||||
order = [rc.finding.rule_id for rc in r.roots]
|
||||
assert order.index("rtc.clock_not_running") < order.index("mic.disabled")
|
||||
assert roots(r)["rtc.clock_not_running"].finding.first_uptime_ms == 15_000
|
||||
|
||||
|
||||
def test_reboot_detected_without_banner():
|
||||
e, r = run(["I (90000) A: running", "I (95000) A: running", "I (1200) B: fresh boot"])
|
||||
assert len(r.boots) == 2 and not r.boots[1].explicit
|
||||
|
||||
|
||||
def test_split_by_sensor():
|
||||
_, r = run(BOOT + APP + [
|
||||
"E (14000) MAIN: Failed to initialize BME688: ESP_FAIL",
|
||||
"E (14001) MAIN: Failed to initialize SEN66: ESP_ERR_TIMEOUT",
|
||||
])
|
||||
titles = {rc.finding.title for rc in r.roots if rc.finding.rule_id == "sensor.init_failed"}
|
||||
assert titles == {"BME688 failed to start", "SEN66 failed to start"}
|
||||
assert r.led == "RED"
|
||||
|
||||
|
||||
def test_rtc_loses_time_after_sync():
|
||||
_, r = run(BOOT + APP + ["I (10222) SNTP: Time synchronized: Thu Sep 17 14:29:22 2026"]
|
||||
+ [f"W ({30000 + i}) BME688: RTC is uninitialized, using current time as timestamp" for i in range(3)])
|
||||
assert "rtc.lost_after_sync" in roots(r)
|
||||
|
||||
|
||||
def test_unknown_firmware_reports_sd_wifi_from_evidence():
|
||||
_, r = run(BOOT + ["I (1317) app_init: ELF file SHA256: 0123456789..."] + [
|
||||
"I (1523) WIFI_CREDENTIALS: SSID/PASSWORD not set in .env; Matter will be used"])
|
||||
assert r.firmware.known is None and r.firmware.supports_sd_wifi is True
|
||||
assert "OTA" in r.firmware.describe()
|
||||
|
||||
|
||||
def test_report_text_renders():
|
||||
text = replay("live_boot_A4CB8FC946F8_fw1.0.txt").to_text()
|
||||
assert text.startswith("VERDICT: Problem") and "consequence:" in text
|
||||
|
||||
|
||||
# ------------------------------------------------------------- log files
|
||||
|
||||
def test_session_log_format(tmp_path):
|
||||
p = tmp_path / "session.log"
|
||||
p.write_text("[14:00:00.000] --- reset issued by tool ---\n"
|
||||
"[14:00:00.400] ESP-ROM:esp32s3-20210327\n"
|
||||
"[14:00:00.401] rst:0x15 (USB_UART_CHIP_RESET),boot:0x2b (SPI_FAST_FLASH_BOOT)\n"
|
||||
"[14:00:01.500] I (1299) app_init: Project name: kota-sensorboard\n", encoding="utf-8")
|
||||
lines = read_log_file(p)
|
||||
assert [ln.kind for ln in lines] == [LineKind.HOST, LineKind.BOOT, LineKind.RESET, LineKind.LOG]
|
||||
e = DiagnosticsEngine()
|
||||
for ln in lines:
|
||||
e.feed(ln)
|
||||
assert e.boot.tool_reset
|
||||
+23
-14
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.core.logline import LineFramer, LineKind, parse_line
|
||||
from kota_tech.core.logline import LineFramer, LineKind, parse_line, split_glued
|
||||
|
||||
FIXTURES = sorted((Path(__file__).parent / "fixtures").glob("*.txt"))
|
||||
|
||||
@@ -39,27 +39,35 @@ def test_framer_splits_and_idle_flushes():
|
||||
|
||||
|
||||
def test_framer_drops_stale_fragment_after_open():
|
||||
f = LineFramer(drop_first_partial=True, now=0.0)
|
||||
assert f.feed(b"current time as t", now=0.01) == []
|
||||
assert f.feed(b"imestamp\r\nW (15899) DeviceStatus: RTC\r\n", now=0.02) == ["W (15899) DeviceStatus: RTC\r"]
|
||||
# Real capture: stale FIFO tail released together with the next live line.
|
||||
f = LineFramer(drop_first_partial=True)
|
||||
got = f.feed(b"I (96) boot: 6 ota_1 OTA app 00 11 00340000"
|
||||
b"W (19497) BME688: RTC is uninitialized\r\nI (19820) SEN66: x\r\n")
|
||||
assert got == ["W (19497) BME688: RTC is uninitialized\r", "I (19820) SEN66: x\r"]
|
||||
|
||||
|
||||
def test_framer_keeps_first_line_from_idle_board():
|
||||
# Board was silent at open; the boot banner arriving later must survive.
|
||||
f = LineFramer(drop_first_partial=True, now=0.0)
|
||||
assert f.feed(b"", now=0.1) == []
|
||||
assert f.feed(b"ESP-ROM:esp32s3-20210327\r\n", now=5.0) == ["ESP-ROM:esp32s3-20210327\r"]
|
||||
# Board silent at open; the boot banner arriving later must survive.
|
||||
f = LineFramer(drop_first_partial=True)
|
||||
assert f.feed(b"ESP-ROM:esp32s3-20210327\r\n") == ["ESP-ROM:esp32s3-20210327\r"]
|
||||
|
||||
|
||||
def test_framer_cancel_drop():
|
||||
f = LineFramer(drop_first_partial=True, now=0.0)
|
||||
f.cancel_drop()
|
||||
assert f.feed(b"ESP-ROM:esp32s3\n", now=0.01) == ["ESP-ROM:esp32s3"]
|
||||
def test_framer_keeps_complete_first_line():
|
||||
f = LineFramer(drop_first_partial=True)
|
||||
assert f.feed(b"W (1) A: complete\r\n") == ["W (1) A: complete\r"]
|
||||
|
||||
|
||||
def test_framer_splits_interleaved_lines():
|
||||
f = LineFramer()
|
||||
assert f.feed(b"I (2302) chip[DL]: I (2302) chip[DL]: Posting event\n") == [
|
||||
"I (2302) chip[DL]: ", "I (2302) chip[DL]: Posting event"]
|
||||
assert f.feed(b"wifi:\x1b[0;32mI (5) X: y\n") == ["wifi:", "\x1b[0;32mI (5) X: y"]
|
||||
|
||||
|
||||
def test_framer_tolerates_invalid_utf8():
|
||||
f = LineFramer()
|
||||
assert f.feed(b"\xff\xfeI (1) X: y\n") == ["��I (1) X: y"]
|
||||
# Garbage in front of a log line is split off rather than corrupting it.
|
||||
assert f.feed(b"\xff\xfeI (1) X: y\n") == ["��", "I (1) X: y"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", FIXTURES, ids=lambda p: p.name)
|
||||
@@ -79,6 +87,7 @@ def test_replay_sample_log_chunked_equals_whole(path):
|
||||
@pytest.mark.parametrize("path", FIXTURES, ids=lambda p: p.name)
|
||||
def test_replay_sample_log_parses(path):
|
||||
lines = [parse_line(r) for r in LineFramer().feed(path.read_bytes())]
|
||||
raw_errors = sum(1 for r in path.read_text("utf-8", "replace").splitlines() if r.startswith("E ("))
|
||||
raw_parts = [p for r in path.read_text("utf-8", "replace").splitlines() for p in split_glued(r)]
|
||||
raw_errors = sum(1 for p in raw_parts if p.startswith("E ("))
|
||||
assert sum(1 for ln in lines if ln.level == "E") == raw_errors
|
||||
assert raw_errors > 0
|
||||
@@ -33,7 +33,7 @@ def test_loopback_lines_reset_and_disconnect(monkeypatch):
|
||||
m.connect(LOOP)
|
||||
assert _wait(lambda: m.state == State.CONNECTED)
|
||||
|
||||
m.write(b"stale tail\r\nESP-ROM:esp32s3-20210327\r\nE (5) INFLUX: token: s3cr3t\r\npartial")
|
||||
m.write(b"stale tailESP-ROM:esp32s3-20210327\r\nE (5) INFLUX: token: s3cr3t\r\npartial")
|
||||
assert _wait(lambda: any(ln.text == "partial" for ln in lines)) # idle flush
|
||||
assert not any("stale" in ln.text for ln in lines) # first fragment after open dropped
|
||||
m.reset_board()
|
||||
|
||||
Reference in new issue
Block a user