Technician tool v0.3
Imported from the Google Drive folder 004-technician_tool/v0.3.
This commit is contained in:
1 parent
e8b72b743d
commit
5e33e2dfee
13 files changed
+2280
-573
No files matched your search
@@ -0,0 +1,90 @@
|
||||
# Firmware diagnostics improvement proposals
|
||||
|
||||
> **Status: draft proposal, AI-generated (Claude) for review by the firmware team.**
|
||||
> Nothing here is implemented. The firmware source was only read for analysis;
|
||||
> no firmware was changed. Every item links to an observation from bench
|
||||
> testing box `A4:CB:8F:C9:46:F8` (firmware v1.0, build `45fa9d5b9`) or from the
|
||||
> sample logs, so the value of each change can be judged.
|
||||
|
||||
## Why
|
||||
|
||||
Troubleshooting currently depends on reverse-engineering free-text log lines and
|
||||
on a status LED whose states overlap. Observed problems:
|
||||
|
||||
| # | Observation | Effect for technicians |
|
||||
|---|---|---|
|
||||
| O1 | A new, uncommissioned box shows **green** for about 60 s, then purple | Looks healthy during installation |
|
||||
| O2 | Three different states share **white** (SD error, factory reset, setup portal) | Needs serial or guesswork |
|
||||
| O3 | **Blue** means "RTC did not answer", but the RTC is just the first I2C device | A loose I2C cable is misreported as an RTC fault |
|
||||
| O4 | Every build reports `App version: 1.0` | Builds are told apart only by ELF SHA |
|
||||
| O5 | Restarts after 150 s in an error state happen **without any log line** | A self-restart looks like a crash |
|
||||
| O6 | With the RTC not running and no Internet, sensor tasks **loop forever** on `RTC is uninitialized` | An offline box records nothing, and it isn't obvious why |
|
||||
| O7 | The OTA token is printed in plain text (`OTA token loaded from NVS: %s`, `received token: %s`) | Secrets end up in technician logs |
|
||||
| O8 | Native USB loses output while no host is reading; a stale FIFO fragment is glued to the next line | The early boot after a power cycle is lost, and lines get corrupted |
|
||||
| O9 | The microphone reports "shutdown state" (GPIO3) with no hint of the cause | A technician can't tell a switch from a fault |
|
||||
| O10 | The deployed build logs some messages under different tags than the source (e.g. SD mount under `INFLUX_BUFFER`) | Parsers must ignore tags |
|
||||
|
||||
## Serial output
|
||||
|
||||
Priority: **P1** = large gain for little effort, **P2** = valuable, **P3** = nice to have.
|
||||
|
||||
### P1
|
||||
|
||||
1. **One structured boot line** with a fixed prefix, printed right after `app_init`:
|
||||
```
|
||||
KOTA: boot fw=1.0.15 build=45fa9d5b hw=2 mac=A4CB8FC946F8 reset=USB_UART_CHIP_RESET mode=matter commissioned=0 last_restart=status_watchdog
|
||||
```
|
||||
The tool can then identify firmware, hardware and mode from a single line (fixes O4 and O10). Fill `PROJECT_VER` from `FIRMWARE_VERSION` (currently `15` in `pinout.hpp`) so every build has a unique version.
|
||||
2. **Log every status change** inside `Status::set_status()`, with a reason argument:
|
||||
```
|
||||
KOTA: status OK -> CONNECTION_ERROR reason=influx_flush_failed
|
||||
```
|
||||
This is the exact LED colour. Today the tool has to infer it from about 15 different messages.
|
||||
3. **Log the reason before every `esp_restart()`** and store it (RTC memory or NVS) so it can be printed at the next boot as `last_restart=`. Values: `status_watchdog_150s`, `out_of_resources_30s`, `button_factory_reset`, `setup_portal_toggle`, `ota_done`, `init_failed:<step>` (fixes O5).
|
||||
4. **Stop printing secrets**: print only the first and last 4 characters of the OTA token, or `token=present` (fixes O7).
|
||||
5. **Warn once and continue when the time is unset**, instead of the `while (now == 946684800)` loops in `device_status.cpp` and the sensors. Record data with a `time_valid=false` field or buffer it with uptime, then correct it after SNTP (fixes O6). This is a behaviour change, but the biggest data-loss risk found.
|
||||
|
||||
### P2
|
||||
|
||||
6. **I2C bus scan at boot**, before any sensor init:
|
||||
```
|
||||
KOTA: i2c SDA=1 SCL=1 0x39=AS7341 0x68=DS1307 0x6B=SEN66(missing) 0x76=BME688
|
||||
```
|
||||
Reading the SDA/SCL levels first detects a stuck-low bus (shorted or broken cable). The scan then separates "whole cable" from "one sensor" (fixes O3).
|
||||
7. **RTC detail**: at init, print the DS1307 clock-halt (CH) bit and the raw time (`KOTA: rtc running=0 time=2000-01-01 (coin cell?)`). After SNTP sets the time, re-read it 2 s later to verify it's ticking.
|
||||
8. **SD card detail**: card type, size, FAT type, free space, which `ENV.TXT` keys are present (names only, never values) and the buffer size in lines. Example: `KOTA: sd type=SDHC size=7.4GB fs=FAT32 free=7.3GB env_keys=INFLUX_URL,INFLUX_TOKEN buffer_lines=42`.
|
||||
9. **Periodic health line** every 60 s. `DeviceStatus` already computes almost everything:
|
||||
```
|
||||
KOTA: health up=312s status=CONNECTION_ERROR wifi=disconnected rssi=0 time_valid=0 sd=ok buffer=42 heap=81234 psram=6.1M last_upload=never ota=unregistered
|
||||
```
|
||||
10. **Matter commissioning status at boot**: `KOTA: matter fabrics=0 window=open` plus the QR payload and manual code, so the tool can show the QR code on screen when the sticker is missing.
|
||||
11. **Microphone enable reason**: `KOTA: mic enabled=0 (GPIO3=1, expected 0)` once at boot, instead of a warning every 10 s (fixes O9).
|
||||
12. **Stable error IDs** in messages, e.g. `[E-SD-01] Failed to initialize the card`, documented in one table shared by the firmware, the technician tool and the service docs.
|
||||
|
||||
### P3
|
||||
|
||||
13. **Console commands** over USB (the firmware already runs a `console` task): `diag` (full health dump), `i2cscan`, `sdinfo`, `ledtest` (cycles all colours so the technician can confirm the LED hardware), `version`, `bootlog`. The tool could then run checks without a reboot.
|
||||
14. **Boot-log ring buffer** in RAM, dumped with `bootlog` or on first host connect. This recovers the early boot lost on native USB after a power-switch cycle (fixes O8).
|
||||
15. **Button events**: log the press start and release with duration (`KOTA: button press 1.2s -> setup_portal`). This allows a safe button test and shows accidental factory resets.
|
||||
|
||||
## RGB LED
|
||||
|
||||
1. **Separate "not connected yet" from "OK"** (fixes O1): **cyan breathing** while the box is uncommissioned / advertising over BLE, fast cyan while a phone is connected, and a green flash when commissioning completes.
|
||||
2. **Split the white states** with patterns (fixes O2):
|
||||
- SD card / `ENV.TXT` error: white, fast blink (2 Hz)
|
||||
- factory reset in progress: white, solid
|
||||
- Wi-Fi setup portal: white, slow breathing
|
||||
3. **Blink codes for red and blue** (fixes O3): N short pulses followed by a pause identify the part.
|
||||
- Red: 1 = BME688, 2 = SEN66, 3 = AS7341, 4 = microphone, 5 = flicker, 6 = self-check failed
|
||||
- Blue: 1 = RTC not responding, 2 = I2C bus stuck low
|
||||
4. **Split purple**: solid = no network/DNS; blinking = Internet works but the server rejected the box (InfluxDB 401/404, OTA 401). These need different fixes (network vs SD card settings).
|
||||
5. **Show the cause before a self-restart**: flash the error colour 3 times before `esp_restart()`. After boot, show the previous boot's error colour for 3 s (from RTC memory), so a reboot loop has a visible cause.
|
||||
6. **Update progress**: yellow blinking while downloading (faster as progress grows); orange stays solid for the self-check.
|
||||
7. **Brightness and distinguishability**: colours are set at 10 % (`255/10`). Yellow and orange, and white and cyan, are hard to tell apart in daylight. Prefer fewer colours plus patterns, and raise brightness for error states.
|
||||
8. **Heartbeat LED (GPIO6)**: keep 1 Hz for "running", add a double-blink when offline, so the heartbeat alone gives a rough state even when the RGB LED is faulty.
|
||||
|
||||
## What changes in the technician tool
|
||||
|
||||
- The structured `KOTA:` lines (items 1, 2, 3, 9) would replace most regex rules in `rules.toml` and make the LED model exact rather than inferred.
|
||||
- The console commands (item 13) would let guided checks test hardware directly (I2C scan, LED test) instead of asking the technician to power-cycle.
|
||||
- The new LED patterns would add answers to the Quick check (e.g. "white, blinking fast") and remove the follow-up questions currently needed to separate the white states.
|
||||
@@ -1,3 +1,3 @@
|
||||
"""KOTA sensorbox technician tool."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
@@ -59,6 +59,7 @@ MILESTONES = [
|
||||
("sd_mounted", "SD card mounted", False),
|
||||
("env_loaded", "ENV.TXT read (InfluxDB settings)", False),
|
||||
("matter_started", "Matter started", True),
|
||||
("matter_commissioned", "Paired in Matter", True),
|
||||
("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True),
|
||||
("sensors_ready", "All sensors started", False),
|
||||
("wifi_connected", "Connected to Wi-Fi", False),
|
||||
@@ -215,6 +216,7 @@ class Report:
|
||||
boots: list[BootRecord]
|
||||
lines: int
|
||||
mid_run: bool # no boot seen yet
|
||||
technician: list[str] = field(default_factory=list) # guided-check answers
|
||||
|
||||
def to_text(self) -> str:
|
||||
out = [f"VERDICT: {self.verdict}",
|
||||
@@ -243,6 +245,10 @@ class Report:
|
||||
out.append("Resolved during this session:")
|
||||
for f in self.resolved:
|
||||
out.append(f" - {f.title}" + (f" (by: {f.resolved_by})" if f.resolved_by else ""))
|
||||
if self.technician:
|
||||
out.append("")
|
||||
out.append("Technician checks:")
|
||||
out += [f" - {t}" for t in self.technician]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@@ -271,6 +277,7 @@ class DiagnosticsEngine:
|
||||
self.lines = 0
|
||||
self._tool_reset_at: float | None = None
|
||||
self._panic_tail = 0 # RAW lines after a panic line to keep
|
||||
self.technician: list[str] = []
|
||||
|
||||
@property
|
||||
def boot(self) -> BootRecord:
|
||||
@@ -369,7 +376,22 @@ class DiagnosticsEngine:
|
||||
# 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)
|
||||
earlier, resolved, list(self.boots), self.lines, mid_run, list(self.technician))
|
||||
|
||||
def add_confirmed(self, key: str, severity: Severity, title: str, meaning: str,
|
||||
actions: tuple[str, ...]) -> Finding:
|
||||
"""A finding established by a guided check (technician answer + log)."""
|
||||
f = self.findings.get(key)
|
||||
if f is None:
|
||||
f = self.findings[key] = Finding(key, key, severity, title, meaning, tuple(actions))
|
||||
f.count += 1
|
||||
f.boot = self.boot.index
|
||||
f.resolved = False
|
||||
f.evidence.append("confirmed by the technician during a guided check")
|
||||
return f
|
||||
|
||||
def note_technician(self, text: str) -> None:
|
||||
self.technician.append(text)
|
||||
|
||||
# ------------------------------------------------------------ internals
|
||||
def _new_boot(self, host_time: float, explicit: bool) -> None:
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Guided troubleshooting: executes the decision trees in guides.toml.
|
||||
|
||||
GUI-independent. The runner is fed the same LogLines as the diagnostics
|
||||
engine, reads the engine's report to route and to skip questions the log
|
||||
already answers, asks the host to perform tool actions (board reset), and
|
||||
records every answer and conclusion in the engine so they appear in the
|
||||
report and the session log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import tomllib
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.logline import LogLine
|
||||
from .engine import LED, DiagnosticsEngine, Severity
|
||||
|
||||
GUIDES_PATH = Path(__file__).with_name("guides.toml")
|
||||
# LED answers a technician can give (engine colours + a reboot-loop pattern).
|
||||
LED_ANSWERS = ["GREEN", "PURPLE", "RED", "BLUE", "WHITE", "YELLOW", "ORANGE", "OFF", "CYCLING"]
|
||||
LED_ANSWER_LABELS = {**{k: v[0] for k, v in LED.items()},
|
||||
"CYCLING": "Keeps changing / goes dark every few seconds"}
|
||||
STEP_TYPES = {"led", "question", "info", "wait", "route"}
|
||||
END_SEVERITY = {"ok": None, "info": Severity.INFO, "warning": Severity.WARNING,
|
||||
"error": Severity.ERROR, "critical": Severity.CRITICAL}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Option:
|
||||
label: str
|
||||
next: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Step:
|
||||
id: str
|
||||
type: str
|
||||
text: str = ""
|
||||
options: tuple[Option, ...] = ()
|
||||
route: dict[str, str] = field(default_factory=dict) # led colour -> target
|
||||
routes: dict[str, str] = field(default_factory=dict) # rule id -> target
|
||||
default: str | None = None
|
||||
next: str | None = None
|
||||
tool_action: str | None = None
|
||||
until: re.Pattern[str] | None = None
|
||||
until_milestone: str | None = None
|
||||
fail_on: re.Pattern[str] | None = None
|
||||
timeout_s: float = 60.0
|
||||
pass_on_timeout: bool = False
|
||||
on_ok: str | None = None
|
||||
on_fail: str | None = None
|
||||
on_timeout: str | None = None
|
||||
skip_if_finding: str | None = None
|
||||
skip_unless_feature: str | None = None
|
||||
skip_to: str | None = None
|
||||
|
||||
def targets(self) -> list[str]:
|
||||
t = [o.next for o in self.options] + list(self.route.values()) + list(self.routes.values())
|
||||
t += [x for x in (self.default, self.next, self.on_ok, self.on_fail, self.on_timeout, self.skip_to) if x]
|
||||
return t
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class End:
|
||||
id: str
|
||||
severity: str
|
||||
title: str
|
||||
text: str
|
||||
actions: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Guide:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
recommend: tuple[str, ...]
|
||||
start: str
|
||||
steps: dict[str, Step]
|
||||
ends: dict[str, End]
|
||||
|
||||
|
||||
def load_guides(path: Path = GUIDES_PATH) -> dict[str, Guide]:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
guides: dict[str, Guide] = {}
|
||||
for g in data["guide"]:
|
||||
steps = {}
|
||||
for s in g.get("step", []):
|
||||
steps[s["id"]] = Step(
|
||||
id=s["id"], type=s["type"], text=s.get("text", ""),
|
||||
options=tuple(Option(o["label"], o["next"]) for o in s.get("options", ())),
|
||||
route=dict(s.get("route", {})), routes=dict(s.get("routes", {})),
|
||||
default=s.get("default"), next=s.get("next"), tool_action=s.get("tool_action"),
|
||||
until=re.compile(s["until"]) if "until" in s else None,
|
||||
until_milestone=s.get("until_milestone"),
|
||||
fail_on=re.compile(s["fail_on"]) if "fail_on" in s else None,
|
||||
timeout_s=float(s.get("timeout_s", 60)), pass_on_timeout=bool(s.get("pass_on_timeout", False)),
|
||||
on_ok=s.get("on_ok"), on_fail=s.get("on_fail"), on_timeout=s.get("on_timeout"),
|
||||
skip_if_finding=s.get("skip_if_finding"), skip_unless_feature=s.get("skip_unless_feature"),
|
||||
skip_to=s.get("skip_to"),
|
||||
)
|
||||
ends = {e["id"]: End(e["id"], e["severity"], e["title"], e["text"], tuple(e.get("actions", ())))
|
||||
for e in g.get("end", [])}
|
||||
guides[g["id"]] = Guide(g["id"], g["title"], g["summary"], tuple(g.get("recommend", ())),
|
||||
g["start"], steps, ends)
|
||||
_validate(guides)
|
||||
return guides
|
||||
|
||||
|
||||
def _validate(guides: dict[str, Guide]) -> None:
|
||||
for g in guides.values():
|
||||
if g.start not in g.steps:
|
||||
raise ValueError(f"guide {g.id}: start step {g.start!r} missing")
|
||||
for s in g.steps.values():
|
||||
if s.type not in STEP_TYPES:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown step type {s.type!r}")
|
||||
if s.type == "led" and set(s.route) - set(LED_ANSWERS):
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown LED colours {set(s.route) - set(LED_ANSWERS)}")
|
||||
if s.type == "wait" and not (s.until or s.until_milestone or s.pass_on_timeout):
|
||||
raise ValueError(f"{g.id}.{s.id}: wait step needs until/until_milestone/pass_on_timeout")
|
||||
if s.tool_action not in (None, "reset"):
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown tool_action {s.tool_action!r}")
|
||||
for t in s.targets():
|
||||
kind, _, ref = t.partition(":")
|
||||
if kind == "end" and ref not in g.ends:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown end {ref!r}")
|
||||
elif kind == "guide" and ref not in guides:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown guide {ref!r}")
|
||||
elif not ref and t not in g.steps:
|
||||
raise ValueError(f"{g.id}.{s.id}: unknown step {t!r}")
|
||||
for e in g.ends.values():
|
||||
if e.severity not in END_SEVERITY:
|
||||
raise ValueError(f"{g.id}.end.{e.id}: unknown severity {e.severity!r}")
|
||||
|
||||
|
||||
def recommended(guides: dict[str, Guide], engine: DiagnosticsEngine) -> list[Guide]:
|
||||
"""Guides matching the current root findings (in root order), then the LED."""
|
||||
report = engine.report()
|
||||
keys: list[str] = []
|
||||
for rc in report.roots:
|
||||
keys.append(rc.finding.rule_id)
|
||||
keys += [c.rule_id for c in rc.consequences]
|
||||
if report.led not in ("UNKNOWN",):
|
||||
keys.append(f"led:{report.led}")
|
||||
out: list[Guide] = [g for g in guides.values() if "always" in g.recommend]
|
||||
for k in keys:
|
||||
for g in guides.values():
|
||||
if k in g.recommend and g not in out:
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class View:
|
||||
"""What the UI should show right now."""
|
||||
guide: Guide | None = None
|
||||
step: Step | None = None
|
||||
text: str = ""
|
||||
options: list[tuple[str, str]] = field(default_factory=list) # (key, label)
|
||||
waiting: bool = False
|
||||
remaining_s: float = 0.0
|
||||
note: str = ""
|
||||
end: End | None = None
|
||||
can_back: bool = False
|
||||
|
||||
|
||||
class GuideRunner:
|
||||
def __init__(self, engine: DiagnosticsEngine, guides: dict[str, Guide] | None = None,
|
||||
on_action: Callable[[str], bool] | None = None,
|
||||
on_log: Callable[[str], None] | None = None,
|
||||
clock: Callable[[], float] = time.monotonic):
|
||||
self.engine = engine
|
||||
self.guides = guides if guides is not None else load_guides()
|
||||
self._on_action = on_action or (lambda _a: False)
|
||||
self._on_log = on_log or (lambda _t: None)
|
||||
self._clock = clock
|
||||
self.cancel()
|
||||
|
||||
# ------------------------------------------------------------ control
|
||||
def cancel(self) -> None:
|
||||
self.guide: Guide | None = None
|
||||
self.step: Step | None = None
|
||||
self.end: End | None = None
|
||||
self.note = ""
|
||||
self._stack: list[tuple[str, str]] = [] # (guide id, step id) for Back
|
||||
self._wait_started = 0.0
|
||||
self._wait_boot = 0
|
||||
self._wait_milestones: set[str] = set()
|
||||
|
||||
def start(self, guide_id: str) -> None:
|
||||
self.cancel()
|
||||
self._enter_guide(guide_id)
|
||||
|
||||
def answer(self, key: str) -> None:
|
||||
"""Pick an option: LED colour name for led steps, option index otherwise."""
|
||||
s = self.step
|
||||
if s is None:
|
||||
return
|
||||
if s.type == "led":
|
||||
label = LED_ANSWER_LABELS.get(key, key)
|
||||
self._record(s, label)
|
||||
self._check_led(key)
|
||||
target = s.route.get(key)
|
||||
elif s.type == "question":
|
||||
opt = s.options[int(key)]
|
||||
self._record(s, opt.label)
|
||||
target = opt.next
|
||||
else:
|
||||
return
|
||||
if target:
|
||||
self._push()
|
||||
self._go(target)
|
||||
|
||||
def proceed(self) -> None:
|
||||
"""Continue on info steps; 'skip waiting' on wait steps."""
|
||||
s = self.step
|
||||
if s is None:
|
||||
return
|
||||
if s.type == "info" and s.next:
|
||||
self._push()
|
||||
self._go(s.next)
|
||||
elif s.type == "wait":
|
||||
self._record(s, "technician skipped the wait")
|
||||
self._finish_wait("timeout")
|
||||
|
||||
def back(self) -> None:
|
||||
# Never step back into a wait: re-entering it could restart the box.
|
||||
while self._stack:
|
||||
gid, sid = self._stack.pop()
|
||||
if self.guides[gid].steps[sid].type != "wait":
|
||||
self.guide, self.end, self.note = self.guides[gid], None, ""
|
||||
self._enter_step(sid)
|
||||
return
|
||||
|
||||
# --------------------------------------------------------------- feed
|
||||
def feed(self, line: LogLine) -> None:
|
||||
s = self.step
|
||||
if s is None or s.type != "wait":
|
||||
return
|
||||
if s.fail_on and s.fail_on.search(line.text):
|
||||
self._record(s, f"log: {line.text}")
|
||||
self._finish_wait("fail")
|
||||
elif s.until and s.until.search(line.text):
|
||||
self._record(s, f"log: {line.text}")
|
||||
self._finish_wait("ok")
|
||||
elif s.until_milestone and self._milestone_new(s.until_milestone):
|
||||
self._record(s, f"milestone reached: {s.until_milestone}")
|
||||
self._finish_wait("ok")
|
||||
|
||||
def tick(self) -> None:
|
||||
s = self.step
|
||||
if s is not None and s.type == "wait" and self._clock() - self._wait_started >= s.timeout_s:
|
||||
self._finish_wait("ok" if s.pass_on_timeout else "timeout")
|
||||
|
||||
# --------------------------------------------------------------- view
|
||||
def view(self) -> View:
|
||||
v = View(guide=self.guide, step=self.step, end=self.end, note=self.note, can_back=bool(self._stack))
|
||||
s = self.step
|
||||
if s is None:
|
||||
return v
|
||||
v.text = self._render(s.text)
|
||||
if s.type == "led":
|
||||
v.options = [(k, LED_ANSWER_LABELS[k]) for k in LED_ANSWERS if k in s.route]
|
||||
elif s.type == "question":
|
||||
v.options = [(str(i), o.label) for i, o in enumerate(s.options)]
|
||||
elif s.type == "wait":
|
||||
v.waiting = True
|
||||
v.remaining_s = max(0.0, s.timeout_s - (self._clock() - self._wait_started))
|
||||
return v
|
||||
|
||||
# ---------------------------------------------------------- internals
|
||||
def _push(self) -> None:
|
||||
if self.guide and self.step:
|
||||
self._stack.append((self.guide.id, self.step.id))
|
||||
|
||||
def _enter_guide(self, guide_id: str) -> None:
|
||||
self.guide = self.guides[guide_id]
|
||||
self._on_log(f"technician: started guide '{self.guide.title}'")
|
||||
self._enter_step(self.guide.start)
|
||||
|
||||
def _go(self, target: str) -> None:
|
||||
kind, _, ref = target.partition(":")
|
||||
if kind == "end" and ref:
|
||||
self._conclude(self.guide.ends[ref])
|
||||
elif kind == "guide" and ref:
|
||||
self._enter_guide(ref)
|
||||
else:
|
||||
self._enter_step(target)
|
||||
|
||||
def _enter_step(self, step_id: str) -> None:
|
||||
s = self.guide.steps[step_id]
|
||||
self.step, self.note = s, ""
|
||||
if s.skip_if_finding and self._finding_active(s.skip_if_finding):
|
||||
self._auto(s, f"skipped - log already shows {s.skip_if_finding}", s.skip_to)
|
||||
return
|
||||
if s.skip_unless_feature and not self._feature(s.skip_unless_feature):
|
||||
self._auto(s, f"skipped - firmware has no {s.skip_unless_feature}", s.skip_to)
|
||||
return
|
||||
if s.type == "route":
|
||||
target = self._route(s)
|
||||
self._auto(s, f"routed by log findings -> {target}", target)
|
||||
return
|
||||
if s.type == "wait":
|
||||
self._wait_started = self._clock()
|
||||
self._wait_boot = self.engine.boot.index
|
||||
self._wait_milestones = set(self.engine.milestones)
|
||||
if s.tool_action and not self._on_action(s.tool_action):
|
||||
self.note = "Could not restart the box from the tool - connect to it first, or switch it off and on."
|
||||
# A milestone that is already reached satisfies the wait at once
|
||||
# unless the step first restarts the box.
|
||||
if s.until_milestone and not s.tool_action and s.until_milestone in self.engine.milestones:
|
||||
self._record(s, f"already reached: {s.until_milestone}")
|
||||
self._finish_wait("ok")
|
||||
|
||||
def _auto(self, s: Step, why: str, target: str | None) -> None:
|
||||
self._on_log(f"technician guide: {s.id}: {why}")
|
||||
if target:
|
||||
self._go(target)
|
||||
|
||||
def _finish_wait(self, outcome: str) -> None:
|
||||
s = self.step
|
||||
target = {"ok": s.on_ok, "fail": s.on_fail or s.on_timeout, "timeout": s.on_timeout or s.on_fail}[outcome]
|
||||
if target is None:
|
||||
return
|
||||
self._push()
|
||||
self._go(target)
|
||||
|
||||
def _milestone_new(self, mid: str) -> bool:
|
||||
if mid not in self.engine.milestones:
|
||||
return False
|
||||
return self.engine.boot.index != self._wait_boot or mid not in self._wait_milestones
|
||||
|
||||
def _route(self, s: Step) -> str:
|
||||
report = self.engine.report()
|
||||
for rc in report.roots:
|
||||
for f in (rc.finding, *rc.consequences):
|
||||
if f.rule_id in s.routes:
|
||||
return s.routes[f.rule_id]
|
||||
return s.default or "end:" + next(iter(self.guide.ends))
|
||||
|
||||
def _check_led(self, seen: str) -> None:
|
||||
expected = self.engine.led
|
||||
if expected in ("UNKNOWN", "OFF") or seen == "CYCLING" or seen == expected:
|
||||
return
|
||||
exp_label, see_label = LED[expected][0], LED_ANSWER_LABELS[seen]
|
||||
self.engine.add_confirmed(
|
||||
"tech.led_mismatch", Severity.WARNING,
|
||||
f"LED shows {see_label}, but the log implies {exp_label}",
|
||||
f"The firmware state in the log corresponds to {exp_label}, while the technician sees {see_label}. "
|
||||
"Either the LED just changed (check again in a few seconds), the LED/its wiring is faulty, "
|
||||
"or the box runs different firmware. Trust the log findings.",
|
||||
("Look at the LED again for 10 seconds.", "If it stays different: note it for repair (LED board)."))
|
||||
|
||||
def _conclude(self, end: End) -> None:
|
||||
self.step, self.end = None, end
|
||||
self._on_log(f"technician guide result: {end.title}")
|
||||
sev = END_SEVERITY[end.severity]
|
||||
self.engine.note_technician(f"{self.guide.title}: {end.title}")
|
||||
if sev is not None:
|
||||
self.engine.add_confirmed(f"tech.{self.guide.id}.{end.id}", sev, end.title, end.text, end.actions)
|
||||
|
||||
def _record(self, s: Step, answer: str) -> None:
|
||||
q = self._render(s.text)
|
||||
self.engine.note_technician(f"{q} -> {answer}")
|
||||
self._on_log(f"technician: {q[:80]} -> {answer}")
|
||||
|
||||
def _finding_active(self, rule_id: str) -> bool:
|
||||
return any(f.rule_id == rule_id and f.active for f in self.engine.findings.values())
|
||||
|
||||
def _feature(self, name: str) -> bool:
|
||||
fw = self.engine.firmware
|
||||
if name == "sd_wifi":
|
||||
return fw.supports_sd_wifi is not False
|
||||
if name == "setup_portal":
|
||||
if fw.known is not None:
|
||||
return fw.known.setup_portal
|
||||
if "setup_portal" in fw.evidence:
|
||||
return True
|
||||
return "legacy_v1" not in fw.evidence # unknown firmware: ask
|
||||
return False
|
||||
|
||||
def _render(self, text: str) -> str:
|
||||
report = self.engine.report()
|
||||
sensors = sorted({f.title.removesuffix(" failed to start") for f in self.engine.findings.values()
|
||||
if f.rule_id == "sensor.init_failed" and f.active})
|
||||
ctx = {
|
||||
"expected_led": LED[report.led][0],
|
||||
"failing_sensors": ", ".join(sensors) or "see the Diagnosis tab",
|
||||
"top_finding": report.roots[0].finding.title if report.roots else "none",
|
||||
}
|
||||
return text.format_map(_Fmt(ctx))
|
||||
|
||||
|
||||
class _Fmt(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
@@ -0,0 +1,715 @@
|
||||
# Guided troubleshooting: decision trees that combine technician answers
|
||||
# with live log evidence (diag.guides.GuideRunner executes them).
|
||||
#
|
||||
# Guide: id, title, summary, recommend (rule ids / "led:<COLOUR>" / "always"),
|
||||
# start (first step id), [[guide.step]], [[guide.end]]
|
||||
# Step types:
|
||||
# led technician picks the LED colour; `route` maps colour -> target.
|
||||
# Colours: GREEN PURPLE RED BLUE YELLOW ORANGE WHITE OFF CYCLING
|
||||
# The answer is compared with the colour the firmware log implies.
|
||||
# question `options` = [{label, next}]
|
||||
# info instruction + Continue (`next`)
|
||||
# wait optional `tool_action` ("reset"), then watch the log:
|
||||
# `until` (regex) / `until_milestone`, `fail_on` (regex),
|
||||
# `timeout_s`; go to on_ok / on_fail / on_timeout.
|
||||
# `pass_on_timeout = true`: reaching the timeout without fail_on
|
||||
# counts as ok (used for "watch for N seconds").
|
||||
# route automatic: first current root finding whose rule id is in
|
||||
# `routes` decides; otherwise `default`.
|
||||
# Targets: "<step id>", "end:<end id>", "guide:<guide id>".
|
||||
# Optional on any step: skip_if_finding / skip_unless_feature (+ skip_to).
|
||||
# Text placeholders: {expected_led} {failing_sensors} {top_finding}
|
||||
# End severity: ok | info | warning | error | critical
|
||||
|
||||
# ================================================================= quick
|
||||
[[guide]]
|
||||
id = "quick"
|
||||
title = "Quick check - start here"
|
||||
summary = "Tell the tool what the LED shows; it picks the right checks."
|
||||
recommend = ["always"]
|
||||
start = "led"
|
||||
|
||||
[[guide.step]]
|
||||
id = "led"
|
||||
type = "led"
|
||||
text = "Look at the RGB status LED on the box (no need to open it). Which colour is it?"
|
||||
route = { GREEN = "heartbeat", PURPLE = "guide:network", RED = "guide:sensors", BLUE = "guide:rtc_i2c", WHITE = "guide:white", YELLOW = "guide:update", ORANGE = "guide:update", OFF = "guide:power", CYCLING = "guide:reboot_loop" }
|
||||
|
||||
[[guide.step]]
|
||||
id = "heartbeat"
|
||||
type = "question"
|
||||
text = "Is the small LED next to the RGB LED blinking about once per second?"
|
||||
options = [
|
||||
{ label = "Yes, blinking", next = "green_route" },
|
||||
{ label = "No - steady on or off", next = "end:hung" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "green_route"
|
||||
type = "route"
|
||||
routes = { "rtc.clock_not_running" = "guide:clock", "mic.silent" = "guide:mic", "mic.disabled" = "guide:mic", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network" }
|
||||
default = "end:healthy"
|
||||
|
||||
[[guide.end]]
|
||||
id = "healthy"
|
||||
severity = "ok"
|
||||
title = "Box looks healthy"
|
||||
text = "Green LED, firmware running and no problems in the log."
|
||||
actions = ["If the customer still reports missing data, keep the tool connected for 5 minutes and check that 'Data uploaded to InfluxDB' gets ticked."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "hung"
|
||||
severity = "error"
|
||||
title = "Firmware is not running normally"
|
||||
text = "The heartbeat LED should blink once per second whenever the firmware runs. A steady heartbeat LED means the firmware has frozen."
|
||||
actions = [
|
||||
"Switch the box off and on with the power switch.",
|
||||
"If it happens again: save the session log and re-flash the official firmware (Flash tab, coming in v0.4).",
|
||||
]
|
||||
|
||||
# =============================================================== network
|
||||
[[guide]]
|
||||
id = "network"
|
||||
title = "Purple LED / no Internet"
|
||||
summary = "Pairing in Matter, site Wi-Fi and Internet checks, with live confirmation."
|
||||
recommend = ["led:PURPLE", "matter.not_commissioned", "net.dns_fail", "influx.buffer_fail", "ota.registration_failed"]
|
||||
start = "paired"
|
||||
|
||||
[[guide.step]]
|
||||
id = "paired"
|
||||
type = "question"
|
||||
skip_if_finding = "matter.not_commissioned"
|
||||
skip_to = "pair_intro"
|
||||
text = "Was this box already added to the customer's Matter app (Apple Home, Google Home, ...) at this site?"
|
||||
options = [
|
||||
{ label = "Yes, it was paired before", next = "internet" },
|
||||
{ label = "No / not sure", next = "pair_intro" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "internet"
|
||||
type = "question"
|
||||
text = "Does the site Wi-Fi have working Internet? Check with a phone connected to the same Wi-Fi (open any website)."
|
||||
options = [
|
||||
{ label = "Yes, Internet works", next = "changed" },
|
||||
{ label = "No Internet on the site Wi-Fi", next = "end:site_offline" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "changed"
|
||||
type = "question"
|
||||
text = "Was the Wi-Fi name or password changed, or the router replaced, since the box was installed?"
|
||||
options = [
|
||||
{ label = "Yes / possibly", next = "end:repair_needed" },
|
||||
{ label = "No", next = "end:offline_unknown" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "pair_intro"
|
||||
type = "info"
|
||||
text = "The box is not on any Wi-Fi yet: it must be paired in Matter. You need: a phone with Bluetooth on, the customer's Matter app, the 2.4 GHz Wi-Fi password, and the QR sticker of this box. Keep the tool connected - it follows the pairing live."
|
||||
next = "pair_start"
|
||||
|
||||
[[guide.step]]
|
||||
id = "pair_start"
|
||||
type = "wait"
|
||||
text = "In the Matter app choose 'Add device' and scan the QR code on the sticker (or type the 11-digit code printed under it). Keep the phone within 1 m of the box."
|
||||
until = 'MAIN: Commissioning session started'
|
||||
timeout_s = 300
|
||||
on_ok = "pair_wifi"
|
||||
on_timeout = "not_found"
|
||||
|
||||
[[guide.step]]
|
||||
id = "not_found"
|
||||
type = "question"
|
||||
text = "The box has not seen the phone connect yet. What does the app show?"
|
||||
options = [
|
||||
{ label = "Still searching / let me try again", next = "pair_start" },
|
||||
{ label = "'Device not found' or similar", next = "end:ble_not_found" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "pair_wifi"
|
||||
type = "wait"
|
||||
text = "The phone is connected to the box. Follow the app: choose the 2.4 GHz Wi-Fi network and enter its password."
|
||||
until = 'MAIN: Commissioning complete|MAIN: Fabric is committed'
|
||||
fail_on = 'Commissioning failed, fail safe timer expired'
|
||||
timeout_s = 300
|
||||
on_ok = "pair_time"
|
||||
on_fail = "end:pair_failed"
|
||||
on_timeout = "end:pair_failed"
|
||||
|
||||
[[guide.step]]
|
||||
id = "pair_time"
|
||||
type = "wait"
|
||||
text = "Paired! The box is joining the Wi-Fi and getting the time from the Internet..."
|
||||
until_milestone = "time_synced"
|
||||
timeout_s = 120
|
||||
on_ok = "pair_upload"
|
||||
on_timeout = "end:joined_no_internet"
|
||||
|
||||
[[guide.step]]
|
||||
id = "pair_upload"
|
||||
type = "wait"
|
||||
text = "Online. Waiting for the first measurements to reach the database (up to 4 minutes)..."
|
||||
until = 'INFLUX: HTTP request performed, status code: 2\d\d'
|
||||
fail_on = 'INFLUX: Failed to perform HTTP request: (401|403|404)'
|
||||
timeout_s = 240
|
||||
on_ok = "end:online"
|
||||
on_fail = "end:influx_rejected"
|
||||
on_timeout = "end:no_upload"
|
||||
|
||||
[[guide.end]]
|
||||
id = "online"
|
||||
severity = "ok"
|
||||
title = "Box is paired and uploading data"
|
||||
text = "Pairing, Internet time and the first upload all worked."
|
||||
actions = [
|
||||
"The box may now download a firmware update (yellow, then orange LED, a few minutes). Do not switch it off.",
|
||||
"It should end green.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "site_offline"
|
||||
severity = "warning"
|
||||
title = "Site network has no Internet"
|
||||
text = "The box cannot work until the site Wi-Fi reaches the Internet. This is not a box fault."
|
||||
actions = ["Ask the customer to fix the Internet connection, then power-cycle the box."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "repair_needed"
|
||||
severity = "warning"
|
||||
title = "Box must be paired again"
|
||||
text = "The box still remembers the old Wi-Fi. It needs a factory reset and new pairing."
|
||||
actions = [
|
||||
"Hold the Matter button for 5 seconds (LED turns white) - this erases the old pairing.",
|
||||
"Remove the box from the customer's Matter app, then run this guide again and choose 'No / not sure'.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "offline_unknown"
|
||||
severity = "error"
|
||||
title = "Box is paired but cannot reach the Internet - cause unclear"
|
||||
text = "The network works for other devices, but not for this box."
|
||||
actions = [
|
||||
"Check the router does not block new devices (guest isolation, MAC filter). Box MAC is shown in the port list.",
|
||||
"Move the box closer to the router to rule out weak signal.",
|
||||
"If nothing helps: factory reset (hold the Matter button 5 s) and pair again.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "ble_not_found"
|
||||
severity = "error"
|
||||
title = "Phone cannot find the box over Bluetooth"
|
||||
text = "The box only accepts pairing for a limited time after start-up."
|
||||
actions = [
|
||||
"Switch the box off and on to reopen the pairing window, then try again within 10 minutes.",
|
||||
"Check Bluetooth and location permission are on for the Matter app.",
|
||||
"If the box was paired to another app before: hold the Matter button 5 s (factory reset) first.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "pair_failed"
|
||||
severity = "error"
|
||||
title = "Pairing did not finish"
|
||||
text = "The phone connected but the box did not join the Wi-Fi."
|
||||
actions = [
|
||||
"Use a 2.4 GHz network (many routers have a separate 2.4 GHz name).",
|
||||
"Re-enter the Wi-Fi password carefully.",
|
||||
"Switch the box off and on, then pair again.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "joined_no_internet"
|
||||
severity = "error"
|
||||
title = "Box joined the Wi-Fi but has no Internet"
|
||||
text = "Pairing worked, but the box cannot reach time or data servers."
|
||||
actions = [
|
||||
"Check the network allows outgoing connections (HTTPS, NTP) for new devices.",
|
||||
"Try a phone hotspot to rule out the site network.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "influx_rejected"
|
||||
severity = "critical"
|
||||
title = "Database rejected the box (token / bucket)"
|
||||
text = "The box is online but InfluxDB refused the upload. The SD card settings are wrong."
|
||||
actions = ["Re-provision the SD card with the current ENV.TXT (SD card is inside the case)."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "no_upload"
|
||||
severity = "warning"
|
||||
title = "Online, but no upload seen yet"
|
||||
text = "Uploads happen every minute; the first one can be delayed while the air sensor warms up."
|
||||
actions = ["Keep the tool connected a few more minutes and watch the Diagnosis tab."]
|
||||
|
||||
# ================================================================= RTC / I2C
|
||||
[[guide]]
|
||||
id = "rtc_i2c"
|
||||
title = "Blue LED - clock / I2C cable"
|
||||
summary = "Re-seat the I2C cable and verify the start-up live."
|
||||
recommend = ["led:BLUE", "rtc.init_failed", "i2c.errors"]
|
||||
start = "explain"
|
||||
|
||||
[[guide.step]]
|
||||
id = "explain"
|
||||
type = "info"
|
||||
text = "Blue means the clock chip did not answer. It is the first chip on the shared I2C cable between the boards, so a loose cable is the most common cause. This check needs the case opened."
|
||||
next = "reseat"
|
||||
|
||||
[[guide.step]]
|
||||
id = "reseat"
|
||||
type = "info"
|
||||
text = "Switch the power OFF. Open the case. Unplug and firmly re-plug the I2C cable between the boards at both ends. Close the case and switch the power ON. Then press Continue."
|
||||
next = "verify"
|
||||
|
||||
[[guide.step]]
|
||||
id = "verify"
|
||||
type = "wait"
|
||||
text = "Watching the start-up (up to 60 s)..."
|
||||
until_milestone = "sensors_ready"
|
||||
fail_on = 'MAIN: Failed to initialize RTC'
|
||||
timeout_s = 60
|
||||
on_ok = "end:i2c_fixed"
|
||||
on_fail = "damaged"
|
||||
on_timeout = "damaged"
|
||||
|
||||
[[guide.step]]
|
||||
id = "damaged"
|
||||
type = "question"
|
||||
text = "The clock still does not answer. Is the I2C cable or its connector visibly damaged (pinched, broken wire, bent pin)?"
|
||||
options = [
|
||||
{ label = "Yes, damaged", next = "end:replace_cable" },
|
||||
{ label = "No, looks fine", next = "end:rtc_module" },
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "i2c_fixed"
|
||||
severity = "ok"
|
||||
title = "Fixed - all sensors start again"
|
||||
text = "After re-seating the cable the clock and all sensors started."
|
||||
actions = ["Make sure the cable is routed without strain before closing the case."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "replace_cable"
|
||||
severity = "error"
|
||||
title = "Replace the I2C cable"
|
||||
text = "A damaged I2C cable stops the clock and all sensors."
|
||||
actions = ["Replace the cable, then run this check again."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "rtc_module"
|
||||
severity = "error"
|
||||
title = "Clock (RTC) module is probably faulty"
|
||||
text = "The cable is fine but the clock chip does not answer."
|
||||
actions = ["Replace the RTC module, or send the box for repair with the session log."]
|
||||
|
||||
# ==================================================================== white
|
||||
[[guide]]
|
||||
id = "white"
|
||||
title = "White LED - SD card / reset / setup mode"
|
||||
summary = "Tell apart the three white states and check the SD card."
|
||||
recommend = ["led:WHITE", "sd.card_fail", "sd.mount_fail", "sd.env_open_fail", "influx.env_missing", "influx.init_fail"]
|
||||
start = "button"
|
||||
|
||||
[[guide.step]]
|
||||
id = "button"
|
||||
type = "question"
|
||||
text = "Did anyone hold the Matter button (outside the case) for about 5 seconds just before the LED turned white?"
|
||||
options = [
|
||||
{ label = "Yes", next = "end:factory_reset" },
|
||||
{ label = "No / don't know", next = "portal" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "portal"
|
||||
type = "question"
|
||||
skip_unless_feature = "setup_portal"
|
||||
skip_to = "sd_reseat"
|
||||
text = "On a phone, look at the Wi-Fi list: is there a network called 'KotaSensorBox-....'?"
|
||||
options = [
|
||||
{ label = "Yes", next = "end:setup_mode" },
|
||||
{ label = "No", next = "sd_reseat" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "sd_reseat"
|
||||
type = "info"
|
||||
text = "The SD card is probably not readable. This needs the case opened: switch the power OFF, open the case, push the SD card in until it clicks (or remove and re-insert it). Close the case, switch the power ON, then press Continue."
|
||||
next = "sd_verify"
|
||||
|
||||
[[guide.step]]
|
||||
id = "sd_verify"
|
||||
type = "wait"
|
||||
text = "Watching the SD card start-up (up to 60 s)..."
|
||||
until = 'Read INFLUX_URL from \.env file'
|
||||
fail_on = 'Failed to initialize the card|Failed to mount filesystem|Failed to read INFLUX_(URL|TOKEN)|Failed to open \.env file'
|
||||
timeout_s = 60
|
||||
on_ok = "end:sd_fixed"
|
||||
on_fail = "end:sd_reprovision"
|
||||
on_timeout = "end:sd_reprovision"
|
||||
|
||||
[[guide.end]]
|
||||
id = "factory_reset"
|
||||
severity = "info"
|
||||
title = "White = factory reset in progress"
|
||||
text = "The box erased its Matter pairing and restarts."
|
||||
actions = ["Pair it again in the Matter app (Purple LED guide)."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "setup_mode"
|
||||
severity = "info"
|
||||
title = "White = Wi-Fi setup mode"
|
||||
text = "The box is waiting for Wi-Fi settings on its own network."
|
||||
actions = [
|
||||
"Connect a phone to KotaSensorBox-.... and open http://sensorbox.local/ to enter the Wi-Fi,",
|
||||
"or short-press the Matter button (< 2 s) to leave setup mode.",
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "sd_fixed"
|
||||
severity = "ok"
|
||||
title = "Fixed - SD card is read again"
|
||||
text = "The box found the SD card and read ENV.TXT."
|
||||
actions = ["Close the case and watch the Diagnosis tab for a minute."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "sd_reprovision"
|
||||
severity = "critical"
|
||||
title = "SD card is unreadable or has wrong settings"
|
||||
text = "Even after re-seating, the card or its ENV.TXT cannot be used."
|
||||
actions = [
|
||||
"Re-provision the card with the tool (SD tab, coming in v0.5) or use a new card.",
|
||||
"The Diagnosis tab shows which step failed (card, file system or ENV.TXT keys).",
|
||||
]
|
||||
|
||||
# ================================================================== sensors
|
||||
[[guide]]
|
||||
id = "sensors"
|
||||
title = "Red LED - sensor or hardware fault"
|
||||
summary = "Find the failing sensor and verify after re-seating its cable."
|
||||
recommend = ["led:RED", "sensor.init_failed", "sensor.selfcheck_failed", "sys.out_of_resources", "ota.selfcheck_failed"]
|
||||
start = "which"
|
||||
|
||||
[[guide.step]]
|
||||
id = "which"
|
||||
type = "route"
|
||||
routes = { "sensor.init_failed" = "reseat", "rtc.init_failed" = "guide:rtc_i2c", "ota.selfcheck_failed" = "end:selfcheck", "sensor.selfcheck_failed" = "end:selfcheck", "sys.out_of_resources" = "end:oom" }
|
||||
default = "capture"
|
||||
|
||||
[[guide.step]]
|
||||
id = "capture"
|
||||
type = "wait"
|
||||
text = "The log does not show the cause yet. The tool restarts the box and records its start-up (45 s)..."
|
||||
tool_action = "reset"
|
||||
timeout_s = 45
|
||||
pass_on_timeout = true
|
||||
on_ok = "which_again"
|
||||
|
||||
[[guide.step]]
|
||||
id = "which_again"
|
||||
type = "route"
|
||||
routes = { "sensor.init_failed" = "reseat", "rtc.init_failed" = "guide:rtc_i2c", "sys.out_of_resources" = "end:oom" }
|
||||
default = "end:red_unclear"
|
||||
|
||||
[[guide.step]]
|
||||
id = "reseat"
|
||||
type = "info"
|
||||
text = "Failing: {failing_sensors}. This needs the case opened: switch the power OFF, open the case and re-seat the cable of that sensor. Close the case, switch the power ON, then press Continue."
|
||||
next = "verify"
|
||||
|
||||
[[guide.step]]
|
||||
id = "verify"
|
||||
type = "wait"
|
||||
text = "Watching the sensor start-up (up to 60 s)..."
|
||||
until_milestone = "sensors_ready"
|
||||
fail_on = 'MAIN: Failed to initialize'
|
||||
timeout_s = 60
|
||||
on_ok = "end:sensor_fixed"
|
||||
on_fail = "end:sensor_replace"
|
||||
on_timeout = "end:sensor_replace"
|
||||
|
||||
[[guide.end]]
|
||||
id = "sensor_fixed"
|
||||
severity = "ok"
|
||||
title = "Fixed - all sensors start again"
|
||||
text = "After re-seating, every sensor started."
|
||||
actions = ["Close the case and watch the Diagnosis tab for a minute."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "sensor_replace"
|
||||
severity = "error"
|
||||
title = "Sensor still fails - replace it"
|
||||
text = "The sensor does not start even with a re-seated cable."
|
||||
actions = ["Replace the named sensor (or its cable) and run this check again."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "selfcheck"
|
||||
severity = "error"
|
||||
title = "A firmware update was rejected by its self-check"
|
||||
text = "The box rolled back to its previous firmware because a sensor failed the test."
|
||||
actions = ["Check the sensor named in the Diagnosis tab (inside the case), then power-cycle."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "oom"
|
||||
severity = "error"
|
||||
title = "Firmware ran out of memory"
|
||||
text = "Not a hardware fault; the firmware restarts itself."
|
||||
actions = ["Power-cycle. If it repeats, send the session log to the firmware team."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "red_unclear"
|
||||
severity = "warning"
|
||||
title = "Red LED without a clear cause in the log"
|
||||
text = "The start-up did not show a failing sensor."
|
||||
actions = ["Save the session log and send it to the firmware team."]
|
||||
|
||||
# ============================================================= reboot loop
|
||||
[[guide]]
|
||||
id = "reboot_loop"
|
||||
title = "LED keeps changing / box restarts"
|
||||
summary = "Record several start-ups and find the error just before each restart."
|
||||
recommend = ["led:CYCLING", "reboot.loop", "sys.crash", "reboot.unexpected", "reboot.brownout", "reboot.watchdog"]
|
||||
start = "record"
|
||||
|
||||
[[guide.step]]
|
||||
id = "record"
|
||||
type = "wait"
|
||||
text = "The tool restarts the box and records 45 seconds of start-up..."
|
||||
tool_action = "reset"
|
||||
timeout_s = 45
|
||||
pass_on_timeout = true
|
||||
on_ok = "cause"
|
||||
|
||||
[[guide.step]]
|
||||
id = "cause"
|
||||
type = "route"
|
||||
routes = { "rtc.init_failed" = "guide:rtc_i2c", "sd.card_fail" = "guide:white", "sd.mount_fail" = "guide:white", "sd.env_open_fail" = "guide:white", "influx.env_missing" = "guide:white", "influx.init_fail" = "guide:white", "sensor.init_failed" = "guide:sensors", "reboot.brownout" = "end:power_supply", "matter.not_commissioned" = "guide:network", "net.dns_fail" = "guide:network", "influx.buffer_fail" = "guide:network" }
|
||||
default = "end:loop_unclear"
|
||||
|
||||
[[guide.end]]
|
||||
id = "power_supply"
|
||||
severity = "error"
|
||||
title = "Power supply is too weak or unstable"
|
||||
text = "The chip reset itself because the voltage dropped."
|
||||
actions = ["Check the power supply, cable and connectors; try another supply."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "loop_unclear"
|
||||
severity = "warning"
|
||||
title = "No clear restart cause in 45 s"
|
||||
text = "The box did not show a known start-up error during the recording."
|
||||
actions = ["Keep the tool connected for 5 minutes, then save the session log for the firmware team."]
|
||||
|
||||
# ================================================================== update
|
||||
[[guide]]
|
||||
id = "update"
|
||||
title = "Yellow / orange LED - firmware update"
|
||||
summary = "Wait for the update and its self-check to finish."
|
||||
recommend = ["led:YELLOW", "led:ORANGE", "ota.updating", "ota.selfcheck"]
|
||||
start = "wait"
|
||||
|
||||
[[guide.step]]
|
||||
id = "wait"
|
||||
type = "wait"
|
||||
text = "A firmware update or its self-check is running. Do NOT switch the box off. Waiting up to 10 minutes..."
|
||||
until = 'Passing selfcheck|OTA update successful'
|
||||
fail_on = 'OTA update failed|Failing selfcheck'
|
||||
timeout_s = 600
|
||||
on_ok = "end:update_ok"
|
||||
on_fail = "end:update_failed"
|
||||
on_timeout = "end:update_slow"
|
||||
|
||||
[[guide.end]]
|
||||
id = "update_ok"
|
||||
severity = "ok"
|
||||
title = "Update finished"
|
||||
text = "The new firmware was installed or accepted."
|
||||
actions = ["Wait for the green LED."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "update_failed"
|
||||
severity = "error"
|
||||
title = "Update failed"
|
||||
text = "The box keeps its current firmware and retries later."
|
||||
actions = ["Check the Wi-Fi is stable. The Diagnosis tab shows why it failed."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "update_slow"
|
||||
severity = "warning"
|
||||
title = "Update is taking very long"
|
||||
text = "No end of the update was seen in 10 minutes."
|
||||
actions = ["Check the Wi-Fi signal; leave the box powered and check again later."]
|
||||
|
||||
# =================================================================== power
|
||||
[[guide]]
|
||||
id = "power"
|
||||
title = "LED off"
|
||||
summary = "Power switch, heartbeat LED and USB connection."
|
||||
recommend = ["led:OFF"]
|
||||
start = "switch"
|
||||
|
||||
[[guide.step]]
|
||||
id = "switch"
|
||||
type = "question"
|
||||
text = "Is the power switch on the outside of the case set to ON?"
|
||||
options = [
|
||||
{ label = "It was OFF - I switched it on", next = "boot" },
|
||||
{ label = "It is ON", next = "heartbeat" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "boot"
|
||||
type = "wait"
|
||||
text = "Waiting for the box to start (up to 30 s)..."
|
||||
until_milestone = "app_started"
|
||||
timeout_s = 30
|
||||
on_ok = "end:was_off"
|
||||
on_timeout = "heartbeat"
|
||||
|
||||
[[guide.step]]
|
||||
id = "heartbeat"
|
||||
type = "question"
|
||||
text = "Is the small LED next to the RGB LED blinking?"
|
||||
options = [
|
||||
{ label = "Yes, blinking", next = "end:rgb_led_fault" },
|
||||
{ label = "No", next = "end:no_power" },
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "was_off"
|
||||
severity = "ok"
|
||||
title = "Box was switched off"
|
||||
text = "It starts normally after switching on."
|
||||
actions = ["Run the Quick check again once the LED shows a colour."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "rgb_led_fault"
|
||||
severity = "warning"
|
||||
title = "Firmware runs, but the RGB LED stays dark"
|
||||
text = "The heartbeat LED shows the firmware is running; the RGB LED or its wiring is faulty."
|
||||
actions = ["Use the Diagnosis tab instead of the LED; replace the LED board when convenient."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "no_power"
|
||||
severity = "error"
|
||||
title = "Box does not start"
|
||||
text = "Neither LED is lit: no power or the firmware does not start."
|
||||
actions = [
|
||||
"Check the power supply and cable.",
|
||||
"If the USB port shows up in this tool, re-flash the official firmware (Flash tab, coming in v0.4).",
|
||||
]
|
||||
|
||||
# =================================================================== clock
|
||||
[[guide]]
|
||||
id = "clock"
|
||||
title = "Clock not set"
|
||||
summary = "Why readings are on hold and when the coin cell matters."
|
||||
recommend = ["rtc.clock_not_running", "rtc.lost_after_sync"]
|
||||
start = "internet"
|
||||
|
||||
[[guide.step]]
|
||||
id = "internet"
|
||||
type = "question"
|
||||
text = "The box clock is not set, so readings are on hold. Will the box have Internet at its installation site?"
|
||||
options = [
|
||||
{ label = "Yes", next = "end:clock_auto" },
|
||||
{ label = "No / not yet", next = "end:clock_offline" },
|
||||
]
|
||||
|
||||
[[guide.end]]
|
||||
id = "clock_auto"
|
||||
severity = "info"
|
||||
title = "Clock will be set automatically"
|
||||
text = "On its first Internet connection the box sets its clock and starts recording."
|
||||
actions = ["If the clock is lost again after every power-off, replace the RTC coin cell (inside the case)."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "clock_offline"
|
||||
severity = "error"
|
||||
title = "Box will not record data without Internet"
|
||||
text = "Without the Internet the clock cannot be set, and the firmware holds all readings until it is."
|
||||
actions = [
|
||||
"Give the box Internet access (pair it in Matter).",
|
||||
"Replace the RTC coin cell (inside the case) so the time survives power-offs.",
|
||||
]
|
||||
|
||||
# ===================================================================== mic
|
||||
[[guide]]
|
||||
id = "mic"
|
||||
title = "Microphone check"
|
||||
summary = "Switched-off or silent microphone."
|
||||
recommend = ["mic.disabled", "mic.silent", "mic.read_fail"]
|
||||
start = "which"
|
||||
|
||||
[[guide.step]]
|
||||
id = "which"
|
||||
type = "route"
|
||||
routes = { "mic.disabled" = "switch", "mic.silent" = "clap", "mic.read_fail" = "end:mic_faulty" }
|
||||
default = "clap"
|
||||
|
||||
[[guide.step]]
|
||||
id = "switch"
|
||||
type = "question"
|
||||
text = "The firmware reads the microphone as switched off. Does this box have a microphone ON/OFF switch or jumper (inside the case)?"
|
||||
options = [
|
||||
{ label = "Yes - I set it to ON", next = "switch_verify" },
|
||||
{ label = "No such switch / not sure", next = "end:mic_enable_line" },
|
||||
{ label = "It is meant to be off", next = "end:mic_off_ok" },
|
||||
]
|
||||
|
||||
[[guide.step]]
|
||||
id = "switch_verify"
|
||||
type = "wait"
|
||||
text = "The tool restarts the box and waits for a microphone reading (up to 60 s)..."
|
||||
tool_action = "reset"
|
||||
until = 'MICROPHONE: Read \d+ bytes'
|
||||
fail_on = 'Microphone is in shutdown state'
|
||||
timeout_s = 60
|
||||
on_ok = "end:mic_on"
|
||||
on_fail = "end:mic_enable_line"
|
||||
on_timeout = "end:mic_enable_line"
|
||||
|
||||
[[guide.step]]
|
||||
id = "clap"
|
||||
type = "wait"
|
||||
text = "Clap your hands or speak loudly right next to the box for about 20 seconds..."
|
||||
until = 'dB peak: (?!-inf)'
|
||||
timeout_s = 45
|
||||
on_ok = "end:mic_ok"
|
||||
on_timeout = "end:mic_faulty"
|
||||
|
||||
[[guide.end]]
|
||||
id = "mic_on"
|
||||
severity = "ok"
|
||||
title = "Microphone is on and recording"
|
||||
text = "The firmware now reads audio samples."
|
||||
actions = []
|
||||
|
||||
[[guide.end]]
|
||||
id = "mic_off_ok"
|
||||
severity = "info"
|
||||
title = "Microphone intentionally off"
|
||||
text = "No noise data is recorded for this box; everything else works."
|
||||
actions = []
|
||||
|
||||
[[guide.end]]
|
||||
id = "mic_enable_line"
|
||||
severity = "warning"
|
||||
title = "Microphone enable line stays 'off'"
|
||||
text = "The firmware keeps reading the microphone as switched off (GPIO3)."
|
||||
actions = ["Check the microphone board and its cable/connector (inside the case); send the box MAC to the firmware team to confirm the hardware variant."]
|
||||
|
||||
[[guide.end]]
|
||||
id = "mic_ok"
|
||||
severity = "ok"
|
||||
title = "Microphone works"
|
||||
text = "It picked up sound - the earlier silence was just a very quiet room."
|
||||
actions = []
|
||||
|
||||
[[guide.end]]
|
||||
id = "mic_faulty"
|
||||
severity = "error"
|
||||
title = "Microphone records nothing"
|
||||
text = "Even loud noise next to the box gives no signal."
|
||||
actions = ["Check the microphone board/cable (inside the case); replace the microphone if needed."]
|
||||
+572
-565
File diff suppressed because it is too large.
Load diff
@@ -55,7 +55,8 @@ STEPS = [
|
||||
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
||||
"<b>Connect</b>.",
|
||||
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
||||
"Note the RGB LED colour and compare with the table below.",
|
||||
"Read the <b>Diagnosis</b> panel on the right, then open <b>Guided troubleshooting</b> "
|
||||
"and start <b>Quick check</b>: the tool asks what you see and checks the log live.",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Guided troubleshooting panel: renders a diag.guides.GuideRunner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diag.engine import LED, DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner, recommended
|
||||
|
||||
END_STYLE = { # end severity -> (border, background)
|
||||
"ok": ("#2e9e44", "#e3f4e7"),
|
||||
"info": ("#2e6da4", "#e3edf7"),
|
||||
"warning": ("#c77c02", "#fdf0d6"),
|
||||
"error": ("#d9534f", "#f9e0df"),
|
||||
"critical": ("#b03a2e", "#f6d5d1"),
|
||||
}
|
||||
|
||||
|
||||
def _swatch(key: str, size: int = 22) -> QIcon:
|
||||
"""Round LED-colour icon; CYCLING is drawn as a blue-dark-red gradient."""
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(QColor(0, 0, 0, 0))
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if key == "CYCLING":
|
||||
g = QLinearGradient(0, 0, size, 0)
|
||||
for stop, c in ((0.0, "#2f6fd6"), (0.5, "#444444"), (1.0, "#d64541")):
|
||||
g.setColorAt(stop, QColor(c))
|
||||
p.setBrush(QBrush(g))
|
||||
else:
|
||||
p.setBrush(QColor(LED.get(key, ("", "#888888"))[1]))
|
||||
p.setPen(QColor("#777777"))
|
||||
p.drawEllipse(1, 1, size - 2, size - 2)
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class GuidePanel(QWidget):
|
||||
def __init__(self, runner: GuideRunner, engine: DiagnosticsEngine, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.runner, self.engine = runner, engine
|
||||
self._shown_key: tuple | None = None
|
||||
|
||||
# --- picker (no guide running)
|
||||
self.picker = QWidget()
|
||||
pl = QVBoxLayout(self.picker)
|
||||
pl.setContentsMargins(0, 0, 0, 0)
|
||||
pl.addWidget(QLabel("<b>Recommended checks for this box</b> (based on the log):"))
|
||||
self.guide_list = QListWidget()
|
||||
self.guide_list.itemDoubleClicked.connect(lambda it: self._start(it.data(Qt.ItemDataRole.UserRole)))
|
||||
pl.addWidget(self.guide_list, 1)
|
||||
self.start_btn = QPushButton("Start selected check")
|
||||
self.start_btn.clicked.connect(self._start_selected)
|
||||
pl.addWidget(self.start_btn)
|
||||
|
||||
# --- running guide
|
||||
self.card = QFrame()
|
||||
self.card.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
cl = QVBoxLayout(self.card)
|
||||
self.guide_title = QLabel()
|
||||
self.guide_title.setStyleSheet("color:#666")
|
||||
self.step_text = QLabel(wordWrap=True)
|
||||
f = self.step_text.font()
|
||||
f.setPointSize(f.pointSize() + 2)
|
||||
self.step_text.setFont(f)
|
||||
self.step_text.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.options = QVBoxLayout()
|
||||
self.wait_bar = QProgressBar()
|
||||
self.wait_bar.setTextVisible(False)
|
||||
self.wait_label = QLabel()
|
||||
self.note = QLabel(wordWrap=True)
|
||||
self.note.setStyleSheet("color:#b03a2e")
|
||||
self.continue_btn = QPushButton("Continue")
|
||||
self.continue_btn.clicked.connect(self._proceed)
|
||||
self.skip_btn = QPushButton("Skip waiting / it did not work")
|
||||
self.skip_btn.clicked.connect(self._proceed)
|
||||
self.end_box = QLabel(wordWrap=True)
|
||||
self.end_box.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.end_box.setMargin(10)
|
||||
for w in (self.guide_title, self.step_text):
|
||||
cl.addWidget(w)
|
||||
cl.addLayout(self.options)
|
||||
for w in (self.wait_label, self.wait_bar, self.note, self.continue_btn, self.skip_btn, self.end_box):
|
||||
cl.addWidget(w)
|
||||
cl.addStretch(1)
|
||||
nav = QHBoxLayout()
|
||||
self.back_btn = QPushButton("← Back")
|
||||
self.back_btn.clicked.connect(self._back)
|
||||
self.done_btn = QPushButton("Back to checks list")
|
||||
self.done_btn.clicked.connect(self._cancel)
|
||||
nav.addWidget(self.back_btn)
|
||||
nav.addStretch(1)
|
||||
nav.addWidget(self.done_btn)
|
||||
cl.addLayout(nav)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 0, 0, 0)
|
||||
lay.addWidget(self.picker, 1)
|
||||
lay.addWidget(self.card, 1)
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
def _start_selected(self) -> None:
|
||||
it = self.guide_list.currentItem() or self.guide_list.item(0)
|
||||
if it:
|
||||
self._start(it.data(Qt.ItemDataRole.UserRole))
|
||||
|
||||
def _start(self, guide_id: str) -> None:
|
||||
self.runner.start(guide_id)
|
||||
self.refresh()
|
||||
|
||||
def _answer(self, key: str) -> None:
|
||||
self.runner.answer(key)
|
||||
self.refresh()
|
||||
|
||||
def _proceed(self) -> None:
|
||||
self.runner.proceed()
|
||||
self.refresh()
|
||||
|
||||
def _back(self) -> None:
|
||||
self.runner.back()
|
||||
self.refresh()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.runner.cancel()
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------- render
|
||||
def refresh(self) -> None:
|
||||
"""Called after every interaction and periodically (timeouts, countdown)."""
|
||||
self.runner.tick()
|
||||
v = self.runner.view()
|
||||
running = v.guide is not None
|
||||
self.picker.setVisible(not running)
|
||||
self.card.setVisible(running)
|
||||
if not running:
|
||||
self._fill_picker()
|
||||
self._shown_key = None
|
||||
return
|
||||
key = (v.guide.id, v.step.id if v.step else None, v.end.id if v.end else None)
|
||||
if key != self._shown_key:
|
||||
self._shown_key = key
|
||||
self._render_step(v)
|
||||
if v.waiting:
|
||||
total = v.step.timeout_s
|
||||
self.wait_bar.setMaximum(int(total))
|
||||
self.wait_bar.setValue(int(total - v.remaining_s))
|
||||
m, s = divmod(int(v.remaining_s), 60)
|
||||
self.wait_label.setText(f"Watching the live log... {m}:{s:02d} left")
|
||||
self.note.setText(v.note)
|
||||
self.note.setVisible(bool(v.note))
|
||||
self.back_btn.setEnabled(v.can_back)
|
||||
|
||||
def _fill_picker(self) -> None:
|
||||
wanted = [(g.id, g.title, g.summary) for g in recommended(self.runner.guides, self.engine)]
|
||||
current = [(self.guide_list.item(i).data(Qt.ItemDataRole.UserRole)) for i in range(self.guide_list.count())]
|
||||
if [w[0] for w in wanted] == current:
|
||||
return
|
||||
self.guide_list.clear()
|
||||
for gid, title, summary in wanted:
|
||||
it = QListWidgetItem(f"{title}\n {summary}")
|
||||
it.setData(Qt.ItemDataRole.UserRole, gid)
|
||||
self.guide_list.addItem(it)
|
||||
self.guide_list.setCurrentRow(0)
|
||||
|
||||
def _render_step(self, v) -> None:
|
||||
self.guide_title.setText(v.guide.title)
|
||||
while self.options.count():
|
||||
w = self.options.takeAt(0).widget()
|
||||
if w:
|
||||
# Detach now; deleteLater alone leaves it visible until the
|
||||
# event loop processes the deferred delete.
|
||||
w.hide()
|
||||
w.setParent(None)
|
||||
w.deleteLater()
|
||||
is_end = v.end is not None
|
||||
self.step_text.setVisible(not is_end)
|
||||
self.step_text.setText(_esc(v.text))
|
||||
for opt_key, label in v.options:
|
||||
b = QPushButton(label)
|
||||
b.setMinimumHeight(34)
|
||||
if v.step and v.step.type == "led":
|
||||
b.setIcon(_swatch(opt_key))
|
||||
b.setIconSize(QSize(22, 22))
|
||||
b.setStyleSheet("QPushButton { text-align:left; padding-left:10px; }")
|
||||
b.clicked.connect(lambda _=False, k=opt_key: self._answer(k))
|
||||
self.options.addWidget(b)
|
||||
step_type = v.step.type if v.step else None
|
||||
self.continue_btn.setVisible(step_type == "info")
|
||||
self.skip_btn.setVisible(step_type == "wait")
|
||||
self.wait_bar.setVisible(step_type == "wait")
|
||||
self.wait_label.setVisible(step_type == "wait")
|
||||
self.end_box.setVisible(is_end)
|
||||
if is_end:
|
||||
e = v.end
|
||||
border, bg = END_STYLE.get(e.severity, END_STYLE["info"])
|
||||
acts = "".join(f"<li>{_esc(a)}</li>" for a in e.actions)
|
||||
self.end_box.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
||||
self.end_box.setText(f"<h3 style='margin:0'>{_esc(e.title)}</h3><p>{_esc(e.text)}</p>"
|
||||
+ (f"<ol>{acts}</ol>" if acts else ""))
|
||||
@@ -22,13 +22,15 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from .. import __version__
|
||||
from ..core.logfile import read_log_file
|
||||
from ..core.logline import LineKind, LogLine
|
||||
from ..core.logline import LineKind, LogLine, host_event
|
||||
from ..core.session import SessionLog
|
||||
from ..diag.engine import DiagnosticsEngine
|
||||
from ..diag.guides import GuideRunner
|
||||
from ..serialio.manager import SerialManager, State
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
from .cheatsheet import cheatsheet_html
|
||||
from .diag_panel import DiagnosticsPanel
|
||||
from .guide_panel import GuidePanel
|
||||
|
||||
MAX_LINES = 20000
|
||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||
@@ -63,6 +65,7 @@ class SerialTab(QWidget):
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine = DiagnosticsEngine()
|
||||
self._diag_dirty = True
|
||||
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
|
||||
|
||||
# --- controls
|
||||
self.port_combo = QComboBox(minimumWidth=320)
|
||||
@@ -102,9 +105,13 @@ class SerialTab(QWidget):
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
self.diag = DiagnosticsPanel()
|
||||
self.guide = GuidePanel(self._runner, self._engine)
|
||||
self.side_tabs = QTabWidget()
|
||||
self.side_tabs.addTab(self.diag, "Diagnosis")
|
||||
self.side_tabs.addTab(self.guide, "Guided troubleshooting")
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.addWidget(self.view)
|
||||
split.addWidget(self.diag)
|
||||
split.addWidget(self.side_tabs)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 2)
|
||||
split.setSizes([700, 480])
|
||||
@@ -175,6 +182,7 @@ class SerialTab(QWidget):
|
||||
return
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._diag_dirty = True
|
||||
self._session.start(port.mac or port.device)
|
||||
self._manager.connect(port)
|
||||
@@ -206,6 +214,7 @@ class SerialTab(QWidget):
|
||||
def _on_line(self, line: LogLine) -> None:
|
||||
self._session.write(line)
|
||||
self._engine.feed(line)
|
||||
self._runner.feed(line)
|
||||
self._diag_dirty = True
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
@@ -240,6 +249,21 @@ class SerialTab(QWidget):
|
||||
if self._diag_dirty:
|
||||
self._diag_dirty = False
|
||||
self.diag.show_report(self._engine.report())
|
||||
self.guide.refresh() # countdowns and timeouts need a steady tick
|
||||
|
||||
# ---- guided troubleshooting hooks ------------------------------------
|
||||
def _guide_action(self, action: str) -> bool:
|
||||
if action == "reset" and self._manager.state == State.CONNECTED:
|
||||
self._manager.reset_board()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _guide_log(self, text: str) -> None:
|
||||
# Technician answers go into the session log and the log view.
|
||||
line = host_event(text)
|
||||
self._session.write(line)
|
||||
self._append(line)
|
||||
self._diag_dirty = True
|
||||
|
||||
def _copy_report(self) -> None:
|
||||
QGuiApplication.clipboard().setText(self._engine.report().to_text())
|
||||
@@ -252,6 +276,7 @@ class SerialTab(QWidget):
|
||||
lines = read_log_file(Path(path))
|
||||
self.view.clear()
|
||||
self._engine.reset()
|
||||
self._runner.cancel()
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
for line in lines:
|
||||
self._engine.feed(line)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kota-technician-tool"
|
||||
version = "0.2.0"
|
||||
version = "0.3.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.2"
|
||||
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.3"
|
||||
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.2"
|
||||
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.3"
|
||||
set "PATH=%USERPROFILE%\.local\bin;%PATH%"
|
||||
|
||||
where uv >nul 2>nul
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Guided troubleshooting: runner + engine with simulated technician
|
||||
answers, simulated board output and a fake clock."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kota_tech.core.logfile import read_log_file
|
||||
from kota_tech.core.logline import parse_line
|
||||
from kota_tech.diag.engine import DiagnosticsEngine, Severity
|
||||
from kota_tech.diag.guides import LED_ANSWERS, GuideRunner, load_guides, recommended
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
BOOT = ["ESP-ROM:esp32s3-20210327", "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)",
|
||||
"I (1299) app_init: Project name: kota-sensorboard",
|
||||
"I (1317) app_init: ELF file SHA256: 45fa9d5b9..."]
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rig():
|
||||
engine, clock, actions, log = DiagnosticsEngine(), Clock(), [], []
|
||||
runner = GuideRunner(engine, on_action=lambda a: actions.append(a) or True,
|
||||
on_log=log.append, clock=clock)
|
||||
|
||||
def board(*lines):
|
||||
for ln in lines:
|
||||
parsed = parse_line(ln)
|
||||
engine.feed(parsed)
|
||||
runner.feed(parsed)
|
||||
|
||||
return engine, runner, clock, actions, board, log
|
||||
|
||||
|
||||
def _bench(engine):
|
||||
for ln in read_log_file(FIX / "live_boot_A4CB8FC946F8_fw1.0.txt"):
|
||||
engine.feed(ln)
|
||||
|
||||
|
||||
def test_all_guides_load_and_validate():
|
||||
guides = load_guides()
|
||||
assert "quick" in guides and len(guides) >= 8
|
||||
led = guides["quick"].steps["led"]
|
||||
assert set(led.route) == set(LED_ANSWERS) # every colour has a route
|
||||
|
||||
|
||||
def test_recommendations_follow_findings(rig):
|
||||
engine, *_ = rig
|
||||
_bench(engine)
|
||||
ids = [g.id for g in recommended(load_guides(), engine)]
|
||||
assert ids[0] == "quick"
|
||||
assert ids.index("network") < ids.index("clock") # root order preserved
|
||||
assert "mic" in ids
|
||||
|
||||
|
||||
def test_purple_uncommissioned_box_skips_to_pairing_and_follows_it_live(rig):
|
||||
engine, runner, clock, _, board, log = rig
|
||||
_bench(engine) # log: not commissioned, LED purple
|
||||
runner.start("quick")
|
||||
runner.answer("PURPLE")
|
||||
# "Was it paired before?" is skipped: the log already shows it is not.
|
||||
assert runner.step.id == "pair_intro"
|
||||
runner.proceed()
|
||||
assert runner.view().waiting and runner.step.id == "pair_start"
|
||||
board("I (90000) MAIN: Commissioning session started")
|
||||
assert runner.step.id == "pair_wifi"
|
||||
board("I (95000) MAIN: Commissioning complete")
|
||||
assert runner.step.id == "pair_time"
|
||||
board("I (99000) SNTP: Time synchronized: Sat Sep 26 16:00:00 2026")
|
||||
assert runner.step.id == "pair_upload"
|
||||
board("I (120000) INFLUX: HTTP request performed, status code: 204")
|
||||
assert runner.end.id == "online" and runner.end.severity == "ok"
|
||||
assert any("Paired" in t or "paired" in t for t in engine.technician)
|
||||
assert "matter.not_commissioned" not in {rc.finding.rule_id for rc in engine.report().roots}
|
||||
assert any(l.startswith("technician:") for l in log)
|
||||
|
||||
|
||||
def test_pairing_timeout_asks_what_the_app_shows(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
_bench(engine)
|
||||
runner.start("network")
|
||||
runner.proceed() # pair_intro -> pair_start
|
||||
clock.t += 301
|
||||
runner.tick()
|
||||
assert runner.step.id == "not_found"
|
||||
runner.answer("1")
|
||||
assert runner.end.id == "ble_not_found"
|
||||
f = engine.findings["tech.network.ble_not_found"]
|
||||
assert f.severity == Severity.ERROR and f.active
|
||||
|
||||
|
||||
def test_led_mismatch_is_recorded(rig):
|
||||
engine, runner, *_ = rig
|
||||
_bench(engine) # log implies PURPLE
|
||||
runner.start("quick")
|
||||
runner.answer("GREEN")
|
||||
f = engine.findings["tech.led_mismatch"]
|
||||
assert "Green" in f.title and "Purple" in f.title
|
||||
assert runner.step.id == "heartbeat"
|
||||
|
||||
|
||||
def test_led_match_records_nothing(rig):
|
||||
engine, runner, *_ = rig
|
||||
_bench(engine)
|
||||
runner.start("quick")
|
||||
runner.answer("PURPLE")
|
||||
assert "tech.led_mismatch" not in engine.findings
|
||||
|
||||
|
||||
def test_blue_reseat_fixed_after_power_cycle(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
board(*BOOT, "E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT")
|
||||
runner.start("quick")
|
||||
runner.answer("BLUE")
|
||||
runner.proceed()
|
||||
runner.proceed() # -> verify (technician power-cycles)
|
||||
assert runner.step.id == "verify"
|
||||
board(*BOOT, "I (14800) temperature_sensor: Range [20°C ~ 100°C], error < 2°C")
|
||||
assert runner.end.id == "i2c_fixed"
|
||||
|
||||
|
||||
def test_blue_still_failing_then_module(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
board(*BOOT, "E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT")
|
||||
runner.start("rtc_i2c")
|
||||
runner.proceed()
|
||||
runner.proceed()
|
||||
board(*BOOT, "E (3511) MAIN: Failed to initialize RTC: ESP_ERR_TIMEOUT")
|
||||
assert runner.step.id == "damaged"
|
||||
runner.answer("1")
|
||||
assert runner.end.id == "rtc_module"
|
||||
|
||||
|
||||
def test_white_skips_portal_question_on_v1_firmware(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
board(*BOOT) # known v1.0: no setup portal
|
||||
runner.start("white")
|
||||
runner.answer("1") # nobody held the button
|
||||
assert runner.step.id == "sd_reseat"
|
||||
|
||||
|
||||
def test_white_asks_portal_question_on_unknown_firmware(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
board("ESP-ROM:esp32s3-20210327", "I (1317) app_init: ELF file SHA256: 0123456789...")
|
||||
runner.start("white")
|
||||
runner.answer("1")
|
||||
assert runner.step.id == "portal"
|
||||
|
||||
|
||||
def test_red_routes_to_failing_sensor_and_names_it(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
board(*BOOT, "E (14000) MAIN: Failed to initialize SEN66: ESP_ERR_TIMEOUT")
|
||||
runner.start("sensors")
|
||||
assert runner.step.id == "reseat"
|
||||
assert "SEN66" in runner.view().text
|
||||
runner.proceed()
|
||||
board(*BOOT, "E (14000) MAIN: Failed to initialize SEN66: ESP_ERR_TIMEOUT")
|
||||
assert runner.end.id == "sensor_replace"
|
||||
|
||||
|
||||
def test_reboot_loop_resets_board_then_routes_by_cause(rig):
|
||||
engine, runner, clock, actions, board, _ = rig
|
||||
runner.start("reboot_loop")
|
||||
assert actions == ["reset"]
|
||||
board(*BOOT, "E (1519) SD_CARD: Failed to initialize the card (ESP_ERR_TIMEOUT). x")
|
||||
clock.t += 46
|
||||
runner.tick()
|
||||
assert runner.guide.id == "white"
|
||||
|
||||
|
||||
def test_reset_action_failure_is_shown(rig):
|
||||
engine = DiagnosticsEngine()
|
||||
runner = GuideRunner(engine, on_action=lambda a: False, clock=Clock())
|
||||
runner.start("reboot_loop")
|
||||
assert "connect" in runner.view().note.lower()
|
||||
|
||||
|
||||
def test_mic_disabled_switch_on_verified(rig):
|
||||
engine, runner, clock, actions, board, _ = rig
|
||||
_bench(engine)
|
||||
runner.start("mic")
|
||||
assert runner.step.id == "switch"
|
||||
runner.answer("0") # set it to ON
|
||||
assert actions == ["reset"]
|
||||
board(*BOOT, "I (20000) MICROPHONE: Read 300000 bytes in 4936 ms")
|
||||
assert runner.end.id == "mic_on"
|
||||
|
||||
|
||||
def test_mic_clap_test_detects_dead_mic(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
board(*BOOT, *["I (20036) MICROPHONE: Sum SQ: 0.000000, RMS pressure: 0.000000, "
|
||||
"Normalized: 0.000000, dB peak: -inf"] * 2)
|
||||
runner.start("mic")
|
||||
assert runner.step.id == "clap"
|
||||
board("I (30036) MICROPHONE: Sum SQ: 0.000000, RMS pressure: 0.000000, Normalized: 0.000000, dB peak: -inf")
|
||||
clock.t += 46
|
||||
runner.tick()
|
||||
assert runner.end.id == "mic_faulty"
|
||||
|
||||
|
||||
def test_mic_clap_test_passes_on_sound(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
runner.start("mic")
|
||||
board("I (30036) MICROPHONE: Sum SQ: 12.5, RMS pressure: 0.02, Normalized: 0.1, dB peak: 71.3")
|
||||
assert runner.end.id == "mic_ok"
|
||||
|
||||
|
||||
def test_back_never_reenters_a_wait(rig):
|
||||
engine, runner, clock, actions, board, _ = rig
|
||||
runner.start("sensors") # route -> capture (wait, resets board)
|
||||
assert runner.step.id == "capture" and actions == ["reset"]
|
||||
clock.t += 46
|
||||
runner.tick() # -> which_again -> end:red_unclear
|
||||
runner.back()
|
||||
assert actions == ["reset"] # no second reset
|
||||
|
||||
|
||||
def test_skip_wait_and_report_contains_technician_checks(rig):
|
||||
engine, runner, clock, _, board, _ = rig
|
||||
runner.start("update")
|
||||
runner.proceed() # technician skips the wait
|
||||
assert runner.end.id == "update_slow"
|
||||
text = engine.report().to_text()
|
||||
assert "Technician checks:" in text and "Update is taking very long" in text
|
||||
Reference in new issue
Block a user