1 Commits
Author SHA1 Message Date
kkallas e161941672 Technician tool v0.3
Imported from the Google Drive folder 004-technician_tool/v0.3.
2026-09-26 17:14:37 +02:00
13 changed files with 2280 additions and 573 deletions

No files matched your search

+90
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""KOTA sensorbox technician tool.""" """KOTA sensorbox technician tool."""
__version__ = "0.2.0" __version__ = "0.3.0"
+23 -1
View File
@@ -59,6 +59,7 @@ MILESTONES = [
("sd_mounted", "SD card mounted", False), ("sd_mounted", "SD card mounted", False),
("env_loaded", "ENV.TXT read (InfluxDB settings)", False), ("env_loaded", "ENV.TXT read (InfluxDB settings)", False),
("matter_started", "Matter started", True), ("matter_started", "Matter started", True),
("matter_commissioned", "Paired in Matter", True),
("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True), ("local_wifi_mode", "Local Wi-Fi mode (SSID from SD card)", True),
("sensors_ready", "All sensors started", False), ("sensors_ready", "All sensors started", False),
("wifi_connected", "Connected to Wi-Fi", False), ("wifi_connected", "Connected to Wi-Fi", False),
@@ -215,6 +216,7 @@ class Report:
boots: list[BootRecord] boots: list[BootRecord]
lines: int lines: int
mid_run: bool # no boot seen yet mid_run: bool # no boot seen yet
technician: list[str] = field(default_factory=list) # guided-check answers
def to_text(self) -> str: def to_text(self) -> str:
out = [f"VERDICT: {self.verdict}", out = [f"VERDICT: {self.verdict}",
@@ -243,6 +245,10 @@ class Report:
out.append("Resolved during this session:") out.append("Resolved during this session:")
for f in self.resolved: for f in self.resolved:
out.append(f" - {f.title}" + (f" (by: {f.resolved_by})" if f.resolved_by else "")) 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) return "\n".join(out)
@@ -271,6 +277,7 @@ class DiagnosticsEngine:
self.lines = 0 self.lines = 0
self._tool_reset_at: float | None = None self._tool_reset_at: float | None = None
self._panic_tail = 0 # RAW lines after a panic line to keep self._panic_tail = 0 # RAW lines after a panic line to keep
self.technician: list[str] = []
@property @property
def boot(self) -> BootRecord: def boot(self) -> BootRecord:
@@ -369,7 +376,22 @@ class DiagnosticsEngine:
# Reached milestones in the order they happened, then the pending ones. # Reached milestones in the order they happened, then the pending ones.
ms.sort(key=lambda m: (m[2] is None, m[2] or 0)) ms.sort(key=lambda m: (m[2] is None, m[2] or 0))
return Report(verdict, vsev, self.led, self.led_reason, self.firmware, ms, roots, return Report(verdict, vsev, self.led, self.led_reason, self.firmware, ms, roots,
earlier, resolved, list(self.boots), self.lines, mid_run) 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 # ------------------------------------------------------------ internals
def _new_boot(self, host_time: float, explicit: bool) -> None: def _new_boot(self, host_time: float, explicit: bool) -> None:
+400
View File
@@ -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 + "}"
+715
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -55,7 +55,8 @@ STEPS = [
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press " "Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
"<b>Connect</b>.", "<b>Connect</b>.",
"Press <b>Reset &amp; capture</b> to record a full boot log from the first line.", "Press <b>Reset &amp; 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.",
] ]
+216
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 ""))
+27 -2
View File
@@ -22,13 +22,15 @@ from PySide6.QtWidgets import (
from .. import __version__ from .. import __version__
from ..core.logfile import read_log_file from ..core.logfile import read_log_file
from ..core.logline import LineKind, LogLine from ..core.logline import LineKind, LogLine, host_event
from ..core.session import SessionLog from ..core.session import SessionLog
from ..diag.engine import DiagnosticsEngine from ..diag.engine import DiagnosticsEngine
from ..diag.guides import GuideRunner
from ..serialio.manager import SerialManager, State from ..serialio.manager import SerialManager, State
from ..serialio.ports import PortInfo, list_serial_ports from ..serialio.ports import PortInfo, list_serial_ports
from .cheatsheet import cheatsheet_html from .cheatsheet import cheatsheet_html
from .diag_panel import DiagnosticsPanel from .diag_panel import DiagnosticsPanel
from .guide_panel import GuidePanel
MAX_LINES = 20000 MAX_LINES = 20000
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}} 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._counts = {"E": 0, "W": 0, "boots": 0}
self._engine = DiagnosticsEngine() self._engine = DiagnosticsEngine()
self._diag_dirty = True self._diag_dirty = True
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
# --- controls # --- controls
self.port_combo = QComboBox(minimumWidth=320) self.port_combo = QComboBox(minimumWidth=320)
@@ -102,9 +105,13 @@ class SerialTab(QWidget):
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }") self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
self.diag = DiagnosticsPanel() 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 = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(self.view) split.addWidget(self.view)
split.addWidget(self.diag) split.addWidget(self.side_tabs)
split.setStretchFactor(0, 3) split.setStretchFactor(0, 3)
split.setStretchFactor(1, 2) split.setStretchFactor(1, 2)
split.setSizes([700, 480]) split.setSizes([700, 480])
@@ -175,6 +182,7 @@ class SerialTab(QWidget):
return return
self._counts = {"E": 0, "W": 0, "boots": 0} self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine.reset() self._engine.reset()
self._runner.cancel()
self._diag_dirty = True self._diag_dirty = True
self._session.start(port.mac or port.device) self._session.start(port.mac or port.device)
self._manager.connect(port) self._manager.connect(port)
@@ -206,6 +214,7 @@ class SerialTab(QWidget):
def _on_line(self, line: LogLine) -> None: def _on_line(self, line: LogLine) -> None:
self._session.write(line) self._session.write(line)
self._engine.feed(line) self._engine.feed(line)
self._runner.feed(line)
self._diag_dirty = True self._diag_dirty = True
if line.kind == LineKind.BOOT: if line.kind == LineKind.BOOT:
self._counts["boots"] += 1 self._counts["boots"] += 1
@@ -240,6 +249,21 @@ class SerialTab(QWidget):
if self._diag_dirty: if self._diag_dirty:
self._diag_dirty = False self._diag_dirty = False
self.diag.show_report(self._engine.report()) 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: def _copy_report(self) -> None:
QGuiApplication.clipboard().setText(self._engine.report().to_text()) QGuiApplication.clipboard().setText(self._engine.report().to_text())
@@ -252,6 +276,7 @@ class SerialTab(QWidget):
lines = read_log_file(Path(path)) lines = read_log_file(Path(path))
self.view.clear() self.view.clear()
self._engine.reset() self._engine.reset()
self._runner.cancel()
self._counts = {"E": 0, "W": 0, "boots": 0} self._counts = {"E": 0, "W": 0, "boots": 0}
for line in lines: for line in lines:
self._engine.feed(line) self._engine.feed(line)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kota-technician-tool" name = "kota-technician-tool"
version = "0.2.0" version = "0.3.0"
description = "KOTA sensorbox technician diagnostic tool (demo)" description = "KOTA sensorbox technician diagnostic tool (demo)"
requires-python = ">=3.11,<3.14" requires-python = ">=3.11,<3.14"
dependencies = [ dependencies = [
+1 -1
View File
@@ -3,7 +3,7 @@
cd "$(dirname "$0")" || exit 1 cd "$(dirname "$0")" || exit 1
# Keep the virtual environment out of the (Google Drive) tool folder. # Keep the virtual environment out of the (Google Drive) tool folder.
export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.2" export UV_PROJECT_ENVIRONMENT="$HOME/Library/Caches/KotaTechnicianTool/venv-0.3"
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
if ! command -v uv >/dev/null 2>&1; then if ! command -v uv >/dev/null 2>&1; then
+1 -1
View File
@@ -3,7 +3,7 @@ setlocal
cd /d "%~dp0" cd /d "%~dp0"
rem Keep the virtual environment out of the (Google Drive) tool folder. rem Keep the virtual environment out of the (Google Drive) tool folder.
set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.2" set "UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\KotaTechnicianTool\venv-0.3"
set "PATH=%USERPROFILE%\.local\bin;%PATH%" set "PATH=%USERPROFILE%\.local\bin;%PATH%"
where uv >nul 2>nul where uv >nul 2>nul
+231
View File
@@ -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