diff --git a/docs/CHANGES-v0.10.md b/docs/CHANGES-v0.10.md index 501fce4..78906b9 100644 --- a/docs/CHANGES-v0.10.md +++ b/docs/CHANGES-v0.10.md @@ -24,3 +24,18 @@ The token is never written to the session log or the service report. - macOS: Connect no longer restarts the box (pyserial dropped DTR before RTS, passing through the USB-Serial-JTAG reset state). Windows unchanged. +- **Serial monitor redesigned.** One green **Test box** button: restarts the box, records the start-up and + then checks InfluxDB online (data must be newer than the restart), about 4 minutes. A status strip shows + what the test is doing and its result. The test checklist (start-up steps + the new row + "Sensor data in InfluxDB (online check)") and the findings are the main view; the live log, guided + troubleshooting and box details (LED, firmware, self-report) are tabs on the right. Rarely used actions + moved to **More**. +- Light colour scheme on every system: with macOS dark mode the status colours were unreadable. + Larger log font on macOS. +- New finding **Box sends an invalid value (-inf/nan) - InfluxDB rejects all its data**: a silent microphone + makes the firmware upload `dbfs=-inf,db=-inf`; InfluxDB then refuses the whole upload (HTTP 400), so neither + the status nor the sensor data arrive (box A4:CB:8F:C9:47:3C). The InfluxDB check names it as the cause. +- The serial port is opened exclusively on macOS/Linux: a second tool window no longer steals half of the + log lines; it reports that the port is in use. +- Start-up step "Data uploaded to InfluxDB" renamed "Upload accepted by InfluxDB (box log)" - it comes + from the box's own log, the online row checks the database. diff --git a/kota_tech/__main__.py b/kota_tech/__main__.py index 6b93d27..1ee0f58 100644 --- a/kota_tech/__main__.py +++ b/kota_tech/__main__.py @@ -1,13 +1,42 @@ import sys +from PySide6.QtGui import QColor, QPalette from PySide6.QtWidgets import QApplication from .ui.main_window import MainWindow +def light_palette() -> QPalette: + """The same light look on every system. All status colours in the tool are + chosen for a light background; with macOS dark mode they were unreadable.""" + p = QPalette() + roles = { + QPalette.ColorRole.Window: "#f4f5f7", + QPalette.ColorRole.WindowText: "#1d1d1f", + QPalette.ColorRole.Base: "#ffffff", + QPalette.ColorRole.AlternateBase: "#f0f2f5", + QPalette.ColorRole.Text: "#1d1d1f", + QPalette.ColorRole.Button: "#e9ebef", + QPalette.ColorRole.ButtonText: "#1d1d1f", + QPalette.ColorRole.ToolTipBase: "#ffffe8", + QPalette.ColorRole.ToolTipText: "#1d1d1f", + QPalette.ColorRole.PlaceholderText: "#8a8a8a", + QPalette.ColorRole.Highlight: "#2e6da4", + QPalette.ColorRole.HighlightedText: "#ffffff", + QPalette.ColorRole.Link: "#1f5fa8", + QPalette.ColorRole.BrightText: "#ffffff", + } + for role, colour in roles.items(): + p.setColor(role, QColor(colour)) + for role in (QPalette.ColorRole.WindowText, QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText): + p.setColor(QPalette.ColorGroup.Disabled, role, QColor("#9a9a9a")) + return p + + def main() -> int: app = QApplication(sys.argv) app.setStyle("Fusion") + app.setPalette(light_palette()) win = MainWindow() win.show() return app.exec() diff --git a/kota_tech/diag/engine.py b/kota_tech/diag/engine.py index d06c2af..d2c5229 100644 --- a/kota_tech/diag/engine.py +++ b/kota_tech/diag/engine.py @@ -73,7 +73,7 @@ MILESTONES = [ ("wifi_connected", "Connected to Wi-Fi", False), ("time_synced", "Clock set from the Internet", False), ("clock_verified", "Clock keeps time (RTC verified)", True), - ("data_uploaded", "Data uploaded to InfluxDB", False), + ("data_uploaded", "Upload accepted by InfluxDB (box log)", False), ("ota_registered", "Registered for firmware updates", False), ("ota_checked", "Checked for firmware updates", False), ("ota_update_started", "Firmware update started", True), diff --git a/kota_tech/diag/guides.toml b/kota_tech/diag/guides.toml index a4a0a23..ef0b61a 100644 --- a/kota_tech/diag/guides.toml +++ b/kota_tech/diag/guides.toml @@ -750,12 +750,18 @@ type = "influx" text = "Checking in InfluxDB that this box's data arrives: device status, microphone and the sensors (AS7341, BME688, SEN66). Keep the box switched on and online. The tool checks again every 30 s for up to 4 minutes (the air sensor needs about 2 minutes after a restart). The laptop needs Internet for this check." timeout_s = 240 poll_s = 30 -outcomes = { ok = "end:data_ok", sensors_missing = "why", silent = "end:silent", never = "end:never", unavailable = "end:unavailable" } +outcomes = { ok = "end:data_ok", sensors_missing = "why", silent = "why_silent", never = "why_silent", unavailable = "end:unavailable" } + +[[guide.step]] +id = "why_silent" +type = "route" +routes = { "influx.invalid_value" = "end:invalid_value" } +default = "end:silent" [[guide.step]] id = "why" type = "route" -routes = { "kota.rtc_stopped" = "end:sensors_clock", "rtc.clock_not_running" = "end:sensors_clock", "kota.rtc_not_ticking" = "end:sensors_clock", "kota.time_invalid" = "end:sensors_clock", "kota.rtc_read_failed" = "guide:rtc_i2c", "rtc.init_failed" = "guide:rtc_i2c", "sensor.init_failed" = "guide:sensors", "kota.sensor_missing" = "guide:sensors" } +routes = { "influx.invalid_value" = "end:invalid_value", "kota.rtc_stopped" = "end:sensors_clock", "rtc.clock_not_running" = "end:sensors_clock", "kota.rtc_not_ticking" = "end:sensors_clock", "kota.time_invalid" = "end:sensors_clock", "kota.rtc_read_failed" = "guide:rtc_i2c", "rtc.init_failed" = "guide:rtc_i2c", "sensor.init_failed" = "guide:sensors", "kota.sensor_missing" = "guide:sensors" } default = "end:sensors_missing" [[guide.end]] @@ -765,6 +771,16 @@ title = "Box is healthy and its sensor data reaches InfluxDB" text = "Status, microphone and all sensor readings of this box arrived in InfluxDB in the last 15 minutes." actions = [] +[[guide.end]] +id = "invalid_value" +severity = "critical" +title = "InfluxDB rejects every upload of this box (invalid value -inf)" +text = "The box is online and uploads, but one measurement carries -inf (the microphone records pure silence). InfluxDB refuses the whole upload with HTTP 400, so neither the status nor the sensor data arrive." +actions = [ + "Run the Microphone check: a working microphone (and its switch ON) avoids the -inf value.", + "Save the session log and send it to the firmware team: the firmware must skip invalid values.", +] + [[guide.end]] id = "sensors_clock" severity = "error" diff --git a/kota_tech/diag/rules.toml b/kota_tech/diag/rules.toml index 8bfb504..ec3c95b 100644 --- a/kota_tech/diag/rules.toml +++ b/kota_tech/diag/rules.toml @@ -280,6 +280,20 @@ severity = "warning" title = "InfluxDB refused some measurements (HTTP 400)" meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)." actions = ["Save the session log and send it to the firmware team."] +explained_by = ["influx.invalid_value"] + +[[rule]] +id = "influx.invalid_value" +# Line protocol printed by SENSOR_GATHERER / INFLUX: "-inf"/"nan" are not valid +# numbers, and one bad line makes InfluxDB reject the whole upload. +pattern = '^(?:.*format: )?(?P[a-z0-9_]+),device=\S+ \S*=-?(?:inf|nan)\b' +severity = "critical" +title = "Box sends an invalid value (-inf/nan) - InfluxDB rejects all its data" +meaning = "A measurement contains -inf or nan (the microphone: pure silence gives -inf dB). InfluxDB refuses the whole upload with HTTP 400, so status and sensor data from this box are lost too. Firmware bug; a silent microphone triggers it." +actions = [ + "Check the microphone (Microphone check): a working microphone avoids the -inf value.", + "Save the session log and send it to the firmware team: invalid values must be skipped, not uploaded.", +] [[rule]] id = "influx.env_missing" @@ -713,7 +727,7 @@ actions = [ "Check the network has Internet (open a website on a phone on the same Wi-Fi).", "Check INFLUX_URL / INFLUX_TOKEN in ENV.TXT (re-provision the SD card).", ] -explained_by = ["kota.time_invalid", "influx.auth", "influx.not_found", "status.conn.1", "net.dns_fail", "net.tls_fail", "influx.server_error"] +explained_by = ["influx.invalid_value", "kota.time_invalid", "influx.auth", "influx.not_found", "status.conn.1", "net.dns_fail", "net.tls_fail", "influx.server_error"] [[rule]] id = "kota.low_heap" diff --git a/kota_tech/serialio/manager.py b/kota_tech/serialio/manager.py index 18692a9..a17b20a 100644 --- a/kota_tech/serialio/manager.py +++ b/kota_tech/serialio/manager.py @@ -51,6 +51,7 @@ class SerialManager: self._thread: threading.Thread | None = None self._stop = threading.Event() self._target: _Target | None = None + self._open_error = "" self.state = State.DISCONNECTED # ---- public API (any thread) ------------------------------------- @@ -89,9 +90,14 @@ class SerialManager: info = find_port(self._target.serial_number, self._target.device) if info is None: return None + posix = sys.platform != "win32" # serial_for_url also accepts test URLs such as "loop://". + # exclusive: a second tool window must not read the same port (on + # macOS/Linux both would get half of the lines). COM ports on Windows + # are always exclusive. ser = serial.serial_for_url( - info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True + info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True, + **({"exclusive": True} if posix else {}), ) # On USB-Serial-JTAG, RTS=1/DTR=0 holds the chip in reset and DTR=1 # during reset selects download mode. @@ -100,7 +106,6 @@ class SerialManager: # macOS/Linux: the OS asserts both lines on open and pyserial then # writes DTR before RTS, passing through RTS=1/DTR=0 and resetting # the box on every connect. Drop RTS first, then DTR. - posix = sys.platform != "win32" if not posix: ser.dtr = False ser.rts = False @@ -108,8 +113,12 @@ class SerialManager: ser.open() if posix: ser.dtr = False - except (serial.SerialException, OSError): + except (serial.SerialException, OSError) as exc: + busy = "exclusive" in str(exc).lower() or "busy" in str(exc).lower() or "access" in str(exc).lower() + self._open_error = ("The port is used by another program (another Technician Tool window?)" + if busy else "") return None + self._open_error = "" self._target.device = info.device # COM number / node may change return ser @@ -143,7 +152,7 @@ class SerialManager: ser = self._open() if ser is None: if not announced_wait: - self._set_state(State.WAITING, "Plug in / power on the sensorbox") + self._set_state(State.WAITING, self._open_error or "Plug in / power on the sensorbox") announced_wait = True self._drain_cmds(None) time.sleep(REOPEN_POLL_S) diff --git a/kota_tech/service/influx.py b/kota_tech/service/influx.py index 0cc18f9..6eb966f 100644 --- a/kota_tech/service/influx.py +++ b/kota_tech/service/influx.py @@ -28,6 +28,7 @@ DEFAULT_BUCKET = "sensorbox" LOOKBACK = "-30d" FRESH_MIN = 15 # like SEEN_OK_MIN in the service system FUTURE_TOLERANCE_MIN = 10 # newer than now + this: the box clock is ahead +SINCE_SLACK_S = 60 # clock difference box / laptop when counting "since the test started" TIMEOUT_S = 20 # Sensor groups that every box must upload: name -> measurement prefixes. @@ -122,13 +123,17 @@ def _ago(now: dt.datetime, t: dt.datetime) -> str: return f"{minutes / 1440:.0f} days ago" -def classify(latest: dict[str, dt.datetime], now: dt.datetime, fresh_min: int = FRESH_MIN) -> InfluxCheck: +def classify(latest: dict[str, dt.datetime], now: dt.datetime, fresh_min: int = FRESH_MIN, + since: dt.datetime | None = None) -> InfluxCheck: + """`since`: only data newer than this counts (the box was restarted then).""" def newest(prefixes: tuple[str, ...]) -> dt.datetime | None: times = [t for m, t in latest.items() if m.startswith(prefixes)] return max(times) if times else None def fresh(t: dt.datetime | None) -> bool: - return t is not None and (now - t).total_seconds() <= fresh_min * 60 + if t is None or (now - t).total_seconds() > fresh_min * 60: + return False + return since is None or (since - t).total_seconds() <= SINCE_SLACK_S details = [] for label, prefixes in (("Device status", (STATUS,)), ("Microphone", (MIC,)), @@ -155,9 +160,11 @@ def classify(latest: dict[str, dt.datetime], now: dt.datetime, fresh_min: int = + ", ".join(missing), latest, missing, details) if lagging: # Offline now, but the history already shows the fault. - return InfluxCheck(SENSORS_MISSING, f"Box is offline now (last status {_ago(now, status_t)}); " - "before that its status arrived without sensor data: " + ", ".join(missing), + return InfluxCheck(SENSORS_MISSING, f"Nothing new in InfluxDB (last status {_ago(now, status_t)}); " + "before that the status arrived without sensor data: " + ", ".join(missing), latest, missing, details) + if since is not None: + return InfluxCheck(SILENT, "No new data in InfluxDB since the test started", latest, missing, details) return InfluxCheck(SILENT, f"No recent data in InfluxDB (nothing in the last {fresh_min} min)", latest, missing, details) @@ -173,7 +180,8 @@ def query_latest(cfg: InfluxConfig, mac: str) -> dict[str, dt.datetime]: return parse_csv(resp.read().decode("utf-8")) -def check_device(cfg: InfluxConfig | None, mac: str, now: dt.datetime | None = None) -> InfluxCheck: +def check_device(cfg: InfluxConfig | None, mac: str, now: dt.datetime | None = None, + since: dt.datetime | None = None) -> InfluxCheck: """Blocking; run it off the UI thread. Never raises.""" mac = "".join(c for c in (mac or "") if c.isalnum()).upper() if cfg is None or not cfg.token: @@ -191,4 +199,4 @@ def check_device(cfg: InfluxConfig | None, mac: str, now: dt.datetime | None = N return InfluxCheck(UNAVAILABLE, f"InfluxDB not reachable from this computer ({reason})") except (ValueError, csv.Error) as exc: return InfluxCheck(UNAVAILABLE, f"Unexpected InfluxDB answer ({exc})") - return classify(latest, now or dt.datetime.now(dt.timezone.utc)) + return classify(latest, now or dt.datetime.now(dt.timezone.utc), since=since) diff --git a/kota_tech/service/report.py b/kota_tech/service/report.py index 5f3f47c..e9ba332 100644 --- a/kota_tech/service/report.py +++ b/kota_tech/service/report.py @@ -33,14 +33,14 @@ CONNECTIVITY = "Problem z łącznością" _STATUS_BY_RULE = [ ("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER), ("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE), - ("influx.bad_request", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE), + ("influx.bad_request", SOFTWARE), ("influx.invalid_value", SOFTWARE), ("ota.selfcheck_failed", SOFTWARE), ("tech.quick.hung", SOFTWARE), ("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE), ("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE), ("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", HARDWARE), ("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE), ("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY), ("ota.", CONNECTIVITY), ("tech.network.", CONNECTIVITY), - ("tech.influx.sensors_clock", HARDWARE), ("tech.influx.silent", CONNECTIVITY), + ("tech.influx.invalid_value", SOFTWARE), ("tech.influx.sensors_clock", HARDWARE), ("tech.influx.silent", CONNECTIVITY), ("tech.influx.never", CONNECTIVITY), ("tech.influx.", CHECK), # firmware 1.0.16+ (KOTA: lines, status, error IDs) ("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE), diff --git a/kota_tech/ui/diag_panel.py b/kota_tech/ui/diag_panel.py index 39116e7..12ea5b6 100644 --- a/kota_tech/ui/diag_panel.py +++ b/kota_tech/ui/diag_panel.py @@ -1,9 +1,11 @@ """Diagnostics side panel: renders a diag.engine.Report. -Layout: verdict banner, expected LED (colour + pattern + meaning when the -box reports it), firmware, the box's own self-report (KOTA lines), start-up -milestones, a findings tree (root causes; their consequences as children; -titles only) and a wrapped detail pane for the selected finding. +Layout: verdict banner, the test checklist (start-up milestones plus the +online InfluxDB check), a findings tree (root causes; their consequences as +children; titles only) and a wrapped detail pane for the selected finding. +`details` is a separate widget for the host to place: expected LED (colour + +pattern + meaning when the box reports it), firmware and the box's own +self-report (KOTA lines). """ from __future__ import annotations @@ -17,6 +19,7 @@ from PySide6.QtWidgets import ( QListWidget, QListWidgetItem, QPushButton, + QScrollArea, QSizePolicy, QSplitter, QTextBrowser, @@ -43,13 +46,19 @@ BANNER = { } _KEY = Qt.ItemDataRole.UserRole BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")} +# Extra checklist rows (e.g. the InfluxDB check): state -> (mark, colour) +ROW_STATE = {"ok": ("✔", "#1b7a33"), "bad": ("✖", "#b03a2e"), "warn": ("!", "#a86400"), + "running": ("…", "#2e6da4"), "pending": ("○", "#6b6b6b"), "off": ("–", "#6b6b6b"), + "note": ("", "#444444")} def _heading(text: str) -> QLabel: lbl = QLabel(text) f = lbl.font() f.setBold(True) + f.setPointSize(f.pointSize() + 1) lbl.setFont(f) + lbl.setContentsMargins(0, 6, 0, 2) return lbl @@ -87,7 +96,14 @@ class DiagnosticsPanel(QWidget): self.milestones = QListWidget() self.milestones.setFrameShape(QFrame.Shape.NoFrame) self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection) + self.milestones.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.milestones.setWordWrap(True) + self.milestones.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) + self.milestones.setStyleSheet("QListWidget { background: transparent; } QListWidget::item { padding: 2px; }") + mf = self.milestones.font() + mf.setPointSize(mf.pointSize() + 1) + self.milestones.setFont(mf) self.tree = QTreeWidget() self.tree.setHeaderHidden(True) @@ -101,22 +117,31 @@ class DiagnosticsPanel(QWidget): findings.setSizes([220, 260]) lay = QVBoxLayout(self) - lay.setContentsMargins(4, 0, 0, 0) + lay.setContentsMargins(4, 0, 4, 0) lay.addWidget(self.verdict) - lay.addWidget(_heading("Status LED should be")) - lay.addLayout(led_row) - lay.addWidget(_heading("Firmware")) - lay.addWidget(self.firmware) - self.qr_btn = QPushButton("Matter pairing QR code...") - self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone") - lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft) - lay.addWidget(self.box_title) - lay.addWidget(self.box) - lay.addWidget(_heading("Start-up progress (current boot)")) + lay.addWidget(_heading("Test checklist (current boot)")) lay.addWidget(self.milestones) lay.addWidget(_heading("Findings - root causes first; click one for details")) lay.addWidget(findings, 1) + # Box details: placed by the host (a separate tab). + inner = QWidget() + dl = QVBoxLayout(inner) + dl.addWidget(_heading("Status LED should be")) + dl.addLayout(led_row) + dl.addWidget(_heading("Firmware")) + dl.addWidget(self.firmware) + self.qr_btn = QPushButton("Matter pairing QR code...") + self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone") + dl.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft) + dl.addWidget(self.box_title) + dl.addWidget(self.box) + dl.addStretch(1) + self.details = QScrollArea() + self.details.setWidgetResizable(True) + self.details.setFrameShape(QFrame.Shape.NoFrame) + self.details.setWidget(inner) + self._findings: dict[str, Finding] = {} self._selected: str | None = None # key -> expanded, chosen by the user; survives the rebuild on refresh. @@ -126,7 +151,8 @@ class DiagnosticsPanel(QWidget): self.tree.itemExpanded.connect(lambda it: self._remember(it, True)) # ------------------------------------------------------------------ - def show_report(self, r: Report) -> None: + def show_report(self, r: Report, extra_rows: list[tuple[str, str]] = ()) -> None: + """extra_rows: (state, text) appended to the checklist, state in ROW_STATE.""" bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING]) if r.lines == 0: bg, fg = "#e6e6e6", "#333333" @@ -161,10 +187,20 @@ class DiagnosticsPanel(QWidget): done = t is not None it = QListWidgetItem(("✔ " if done else "○ ") + label + (f" ({t / 1000:.1f} s)" if done else "")) - it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a"))) + it.setForeground(QBrush(QColor("#1b7a33" if done else "#6b6b6b"))) self.milestones.addItem(it) - rows = min(max(self.milestones.count(), 1), 15) - self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6) + for state, text in extra_rows: + mark, colour = ROW_STATE[state] + it = QListWidgetItem(f"{mark} {text}" if mark else text) + it.setForeground(QBrush(QColor(colour))) + if state != "note": + f = it.font() + f.setBold(True) + it.setFont(f) + it.setToolTip(text) + self.milestones.addItem(it) + height = sum(max(self.milestones.sizeHintForRow(i), 16) for i in range(self.milestones.count())) + self.milestones.setFixedHeight(min(max(height, 16), 560) + 6) self._fill_tree(r) diff --git a/kota_tech/ui/main_window.py b/kota_tech/ui/main_window.py index 6d146c7..88bbadd 100644 --- a/kota_tech/ui/main_window.py +++ b/kota_tech/ui/main_window.py @@ -1,7 +1,10 @@ from __future__ import annotations +import datetime as dt import os +import sys import threading +import time from pathlib import Path from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal @@ -18,12 +21,14 @@ from PySide6.QtWidgets import ( QLabel, QLineEdit, QMainWindow, + QMenu, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTabWidget, QTextBrowser, + QToolButton, QVBoxLayout, QWidget, ) @@ -49,6 +54,13 @@ from .sd_tab import SdTab from .wifi_tab import WifiTab MAX_LINES = 20000 +TEST_INFLUX_AFTER_S = 30 # Test box: start the online check this long after the restart +INFLUX_ROW = "Sensor data in InfluxDB (online check)" +# test status strip: (background, text colour) +STRIP = {"idle": ("#eef1f4", "#333333"), "running": ("#dde9f7", "#123a63"), "ok": ("#d8f0dd", "#1b5e2a"), + "info": ("#dde9f7", "#123a63"), "warning": ("#fdebc8", "#6b4400"), "error": ("#f9dedc", "#8a2019"), + "critical": ("#f6d5d1", "#7b1e14")} +CLOUD_STATE = {"ok": "ok", "sensors_missing": "bad", "silent": "warn", "never": "warn", "unavailable": "off"} # "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors. LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA", "Warnings + errors": {"W", "E"}, "Errors only": {"E"}} @@ -65,6 +77,10 @@ COLORS = { } +def _esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + class _Bridge(QObject): """Marshals SerialManager callbacks (reader thread) onto the UI thread.""" @@ -90,82 +106,127 @@ class SerialTab(QWidget): self._line_listeners: list = [] # other tabs following the live log (Wi-Fi over USB) # --- controls - self.port_combo = QComboBox(minimumWidth=320) - self.all_ports = QCheckBox("Show all ports") - self.refresh_btn = QPushButton("Refresh") + self._settings = QSettings("KOTA", "TechnicianTool") + self.port_combo = QComboBox(minimumWidth=360) + self.refresh_btn = QPushButton("↻") + self.refresh_btn.setToolTip("Look for boxes again") + self.refresh_btn.setFixedWidth(40) + self.test_btn = QPushButton("▶ Test box") + self.test_btn.setToolTip("Restart the box, check its start-up and whether its sensor data reaches " + "InfluxDB (about 4 minutes)") + self.test_btn.setMinimumHeight(38) + self.test_btn.setStyleSheet( + "QPushButton { background:#1f7a3a; color:white; font-weight:bold; font-size:15px; " + "padding:4px 22px; border-radius:6px; border:none; }" + "QPushButton:hover { background:#25904a; } QPushButton:disabled { background:#9dbfa6; }") self.connect_btn = QPushButton("Connect") - self.reset_btn = QPushButton("Reset && capture") - self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line") + self.connect_btn.setMinimumHeight(32) + + # Less frequent actions live in the "More" menu. + self.more_btn = QToolButton(text="More ▾") + self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.more_btn.setMinimumHeight(32) + menu = QMenu(self.more_btn) + self.reset_btn = menu.addAction("Reset && capture (restart the box, no online check)") + menu.addSeparator() + self.service_btn = menu.addAction("Service report...") + self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis " + "filled in; check it and press Save there") + self.copy_btn = menu.addAction("Copy report") + self.save_btn = menu.addAction("Save log view as...") + self.folder_btn = menu.addAction("Open logs folder") + self.analyze_btn = menu.addAction("Analyze log file...") + menu.addSeparator() + self.auto_service = menu.addAction("Open service report on disconnect") + self.auto_service.setCheckable(True) + self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool)) + self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on)) + self.all_ports = menu.addAction("Show all serial ports") + self.all_ports.setCheckable(True) + self.more_btn.setMenu(menu) + self._active_port: PortInfo | None = None + + top = QHBoxLayout() + box_lbl = QLabel("Box:") + for w in (box_lbl, self.port_combo, self.refresh_btn): + top.addWidget(w) + top.addSpacing(12) + top.addWidget(self.test_btn) + top.addWidget(self.connect_btn) + top.addStretch(1) + top.addWidget(self.more_btn) + + # Test status strip (what the test is doing / its result). + self.test_status = QLabel(wordWrap=True) + self.test_status.setMargin(8) + tf = self.test_status.font() + tf.setPointSize(tf.pointSize() + 1) + self.test_status.setFont(tf) + + # --- live log (a tab on the right) self.filter_combo = QComboBox() self.filter_combo.addItems(LEVEL_FILTERS) self.clear_btn = QPushButton("Clear view") - self.save_btn = QPushButton("Save view as...") - self.folder_btn = QPushButton("Open logs folder") - self.analyze_btn = QPushButton("Analyze log file...") - self.analyze_btn.setToolTip("Load a saved session log or serial capture and diagnose it") - self.copy_btn = QPushButton("Copy report") - self.copy_btn.setToolTip("Copy the diagnosis as text (e.g. to send to the firmware team)") - self.service_btn = QPushButton("Service report...") - self.service_btn.setToolTip("Open this box's page in the KOTA service system with the diagnosis " - "filled in; check it and press Save there") - self._settings = QSettings("KOTA", "TechnicianTool") - self.auto_service = QCheckBox("Open service report on disconnect") - self.auto_service.setChecked(self._settings.value("service/auto", False, type=bool)) - self.auto_service.toggled.connect(lambda on: self._settings.setValue("service/auto", on)) - self._active_port: PortInfo | None = None - - row1 = QHBoxLayout() - for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn, - self.connect_btn, self.reset_btn): - row1.addWidget(w) - row1.addStretch(1) - row2 = QHBoxLayout() - for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn, - self.analyze_btn, self.copy_btn, self.service_btn, self.auto_service): - row2.addWidget(w) - row2.addStretch(1) - self.view = QPlainTextEdit(readOnly=True) self.view.setMaximumBlockCount(MAX_LINES) self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas") mono.setStyleHint(QFont.StyleHint.Monospace) - mono.setPointSize(10) + mono.setPointSize(12 if sys.platform == "darwin" else 10) self.view.setFont(mono) self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }") + log_page = QWidget() + ll = QVBoxLayout(log_page) + ll.setContentsMargins(0, 4, 0, 0) + lrow = QHBoxLayout() + for w in (QLabel("Show:"), self.filter_combo, self.clear_btn): + lrow.addWidget(w) + lrow.addStretch(1) + ll.addLayout(lrow) + ll.addWidget(self.view, 1) self.diag = DiagnosticsPanel() self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload) self.side_tabs = QTabWidget() - self.side_tabs.addTab(self.diag, "Diagnosis") + self.side_tabs.addTab(log_page, "Live log") self.side_tabs.addTab(self.guide, "Guided troubleshooting") + self.side_tabs.addTab(self.diag.details, "Box details") 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]) + split.setStretchFactor(0, 1) + split.setStretchFactor(1, 1) + split.setSizes([560, 620]) self.status = QLabel() + self.status.setStyleSheet("color:#555") layout = QVBoxLayout(self) - layout.addLayout(row1) - layout.addLayout(row2) + layout.addLayout(top) + layout.addWidget(self.test_status) layout.addWidget(split, 1) layout.addWidget(self.status) # --- wiring self.refresh_btn.clicked.connect(self.refresh_ports) self.all_ports.toggled.connect(self.refresh_ports) + self.test_btn.clicked.connect(self._test_box) self.connect_btn.clicked.connect(self._toggle_connect) - self.reset_btn.clicked.connect(self._manager.reset_board) + self.reset_btn.triggered.connect(self._manager.reset_board) self.clear_btn.clicked.connect(self.view.clear) - self.save_btn.clicked.connect(self._save_view) - self.folder_btn.clicked.connect(self._open_folder) - self.analyze_btn.clicked.connect(self._analyze_file) - self.copy_btn.clicked.connect(self._copy_report) - self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False)) + self.save_btn.triggered.connect(self._save_view) + self.folder_btn.triggered.connect(self._open_folder) + self.analyze_btn.triggered.connect(self._analyze_file) + self.copy_btn.triggered.connect(self._copy_report) + self.service_btn.triggered.connect(lambda: self._open_service_report(auto=False)) self.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec()) + # Test box state: None = no test; else monotonic start time. + self._test_t0: float | None = None + self._test_since: dt.datetime | None = None # UTC; InfluxDB data must be newer + self._influx_started = False + self._cloud: influx.InfluxCheck | None = None # last InfluxDB answer + self._cloud_checking = False + # Report refresh is throttled; lines can arrive at >100/s. self._diag_timer = QTimer(self, interval=400) self._diag_timer.timeout.connect(self._refresh_diag) @@ -221,8 +282,98 @@ class SerialTab(QWidget): self._diag_dirty = True self._session.start(port.mac or port.device) self._active_port = port + self._cloud, self._cloud_checking = None, False self._manager.connect(port) + # ---- Test box ---------------------------------------------------------- + def _test_box(self) -> None: + """Restart the box, record its start-up, then check InfluxDB online.""" + if self._manager.is_running: + self._counts = {"E": 0, "W": 0, "boots": 0} + self._engine.reset() + self._runner.cancel() + self._cloud, self._cloud_checking = None, False + self._manager.reset_board() + else: + port = self._selected_port() or next((p for p in self._ports if p.is_sensorbox), None) + if port is None: + QMessageBox.information(self, "Test box", "No sensorbox found. Check the USB cable (data cable) " + "and that the box's power switch is ON, then press ↻.") + return + self.connect_port(port, capture=True) + self._test_t0 = time.monotonic() + self._test_since = dt.datetime.now(dt.timezone.utc) + self._influx_started = False + self._test_end = None + self._diag_dirty = True + self._guide_log("Test box started: restarting the box and recording its start-up") + + def _tick_test(self) -> None: + if self._test_t0 is None: + return + self._diag_dirty = True # countdowns in the strip and the checklist + if not self._manager.is_running: + self._finish_test(None, "Box disconnected - test stopped.") + return + elapsed = time.monotonic() - self._test_t0 + if not self._influx_started and elapsed >= TEST_INFLUX_AFTER_S and self.is_connected: + self._influx_started = True + self._runner.start("influx") # polls, routes and records findings like a guided check + self.guide.refresh() + elif self._influx_started and (self._runner.end is not None or self._runner.guide is None): + self._finish_test(self._runner.end) + + def _finish_test(self, end, text: str = "") -> None: + self._test_t0 = None + self._test_since = None + self._test_end = end + self._test_text = text + self._guide_log("Test box finished: " + (end.title if end else text or "stopped")) + + def _cloud_rows(self) -> list[tuple[str, str]]: + in_check = self._runner.step is not None and self._runner.step.type == "influx" + if self.influx_config() is None: + return [("off", f"{INFLUX_ROW}: not set up - Settings > InfluxDB access")] + if in_check: + left = int(self._runner.view().remaining_s) + last = f" - last answer: {self._cloud.summary}" if self._cloud else "" + rows = [("running", f"{INFLUX_ROW}: checking... {left // 60}:{left % 60:02d} left{last}")] + elif self._cloud is not None: + rows = [(CLOUD_STATE.get(self._cloud.outcome, "warn"), f"{INFLUX_ROW}: {self._cloud.summary}")] + else: + when = (f"starts {TEST_INFLUX_AFTER_S} s after the restart" if self._test_t0 is not None + else "press Test box") + return [("pending", f"{INFLUX_ROW} - {when}")] + if self._cloud is not None: + rows += [("note", f" {d}") for d in self._cloud.details] + return rows + + def _update_test_strip(self) -> None: + if self._test_t0 is not None: + el = int(time.monotonic() - self._test_t0) + if self._influx_started: + doing = "checking that the data reaches InfluxDB - " + ( + self._runner.view().note or "waiting for the first answer") + else: + ms = self._engine.report().milestones + done = sum(t is not None for _, _, t in ms) + doing = f"recording the start-up ({done} of {len(ms)} checklist steps done)" + key, text = "running", f"Test running {el // 60}:{el % 60:02d} - {_esc(doing)}" + elif getattr(self, "_test_end", None) is not None: + e = self._test_end + key = e.severity + text = (f"Test finished: {_esc(e.title)}
{_esc(e.text)} " + "Details: Findings below and the Guided troubleshooting tab.") + elif getattr(self, "_test_text", ""): + key, text = "warning", f"{_esc(self._test_text)}" + else: + key = "idle" + text = ("Press ▶ Test box: the tool restarts the box, checks its start-up (sensors, SD card, " + "Wi-Fi, clock) and then whether its sensor data reaches InfluxDB. Takes about 4 minutes.") + bg, fg = STRIP[key] + self.test_status.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}") + self.test_status.setText(text) + def matter_payload(self) -> SetupPayload: """Pairing payload reported by this box (KOTA: matter), from its log, or the defaults.""" return payload_from_log([self._engine.matter_advert] if self._engine.matter_advert else [], @@ -317,6 +468,7 @@ class SerialTab(QWidget): self._session.stop() self.connect_btn.setText("Disconnect" if running else "Connect") self.reset_btn.setEnabled(state == State.CONNECTED) + self.test_btn.setEnabled(state != State.WAITING) self.port_combo.setEnabled(not running) self.all_ports.setEnabled(not running) self.refresh_btn.setEnabled(not running) @@ -376,9 +528,11 @@ class SerialTab(QWidget): # ---- diagnostics ------------------------------------------------- def _refresh_diag(self) -> None: + self._tick_test() if self._diag_dirty: self._diag_dirty = False - self.diag.show_report(self._engine.report()) + self.diag.show_report(self._engine.report(), self._cloud_rows()) + self._update_test_strip() self.guide.refresh() # countdowns and timeouts need a steady tick # ---- guided troubleshooting hooks ------------------------------------ @@ -392,7 +546,9 @@ class SerialTab(QWidget): if cfg is None: self._runner.note = "InfluxDB access is not set up: Settings > InfluxDB access." return False - threading.Thread(target=lambda: self._bridge.influx.emit(influx.check_device(cfg, mac)), + since = self._test_since + self._cloud_checking = True + threading.Thread(target=lambda: self._bridge.influx.emit(influx.check_device(cfg, mac, since=since)), name="influx-check", daemon=True).start() return True return False @@ -409,6 +565,7 @@ class SerialTab(QWidget): bucket=str(self._settings.value("influx/bucket", influx.DEFAULT_BUCKET))) def _on_influx(self, check: influx.InfluxCheck) -> None: + self._cloud, self._cloud_checking = check, False self._runner.influx_result(check.outcome, check.summary, check.details) self._diag_dirty = True self.guide.refresh() diff --git a/tests/test_guides.py b/tests/test_guides.py index af230cf..076062a 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -334,3 +334,15 @@ def test_quick_check_green_ends_with_influx_check(influx_rig): runner.answer("GREEN") runner.answer("0") # heartbeat blinking assert runner.guide.id == "influx" and actions == ["influx"] + + +def test_influx_rejected_upload_is_named(influx_rig): + engine, runner, clock, actions, board = influx_rig + board(*BOOT, "microphone,device=A4CB8FC9473C dbfs=-inf,db=-inf 1790805114000000000", + "io,device=A4CB8FC9473C dbfs=-inf,db=-inf 1790805215000000000") # USB dropped characters + assert sum(k.startswith("influx.invalid_value") for k in engine.findings) == 1 + runner.start("influx") + runner.influx_result("silent", "No new data in InfluxDB since the test started") + clock.t += 300 + runner.tick() + assert runner.end.id == "invalid_value" diff --git a/tests/test_influx.py b/tests/test_influx.py index 32029c2..1d84d18 100644 --- a/tests/test_influx.py +++ b/tests/test_influx.py @@ -67,7 +67,7 @@ def test_offline_box_whose_sensors_stopped_before_status(): latest = {"device_status": _t(29 * 60), "microphone": _t(29 * 60), "as7341_adc": _t(21 * 1440), "bme688": _t(21 * 1440), "sen66": _t(21 * 1440)} c = influx.classify(latest, NOW) - assert c.outcome == influx.SENSORS_MISSING and "offline now" in c.summary + assert c.outcome == influx.SENSORS_MISSING and "Nothing new" in c.summary assert any("stopped" in d for d in c.details) assert any("21 days ago" in d for d in c.details) @@ -140,3 +140,15 @@ def test_check_device_sends_token_header_and_normalized_mac(monkeypatch): assert req.get_header("Authorization") == "Token tok" assert "tok" not in req.full_url and req.full_url.endswith("/api/v2/query?org=kota") assert b'"A4CB8FC9473C"' in req.data + + +def test_since_ignores_data_from_before_the_test(): + # healthy a few minutes ago, but nothing since the box was restarted by the test + latest = {"device_status": _t(5), "microphone": _t(5), "as7341_adc": _t(5), "bme688": _t(5), "sen66": _t(5)} + assert influx.classify(latest, NOW).outcome == influx.OK + c = influx.classify(latest, NOW, since=_t(2)) + assert c.outcome == influx.SILENT and "since the test started" in c.summary + latest.update(device_status=_t(0), microphone=_t(0)) + assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.SENSORS_MISSING + latest.update(as7341_adc=_t(0), bme688=_t(0), sen66=_t(2.5)) # within the clock slack + assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.OK diff --git a/tests/test_ui.py b/tests/test_ui.py index 3564ee1..9467b13 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -19,3 +19,34 @@ def test_main_window_builds_with_all_tabs(): assert "InfluxDB access..." in settings w.serial_tab.shutdown() app.processEvents() + + +def test_serial_tab_checklist_and_test_strip(): + from PySide6.QtWidgets import QApplication + + from kota_tech.core.logline import parse_line + from kota_tech.service import influx + from kota_tech.ui.main_window import INFLUX_ROW, MainWindow + app = QApplication.instance() or QApplication([]) + w = MainWindow() + st = w.serial_tab + assert st.test_btn.text().endswith("Test box") + assert [st.side_tabs.tabText(i) for i in range(st.side_tabs.count())] == \ + ["Live log", "Guided troubleshooting", "Box details"] + st.influx_config = lambda: None # no token (never touch real settings) + rows = st._cloud_rows() + assert rows[0][0] == "off" and "not set up" in rows[0][1] + st.influx_config = lambda: influx.InfluxConfig("t") # pretend access is set up + assert st._cloud_rows()[0] == ("pending", f"{INFLUX_ROW} - press Test box") + st._on_influx(influx.InfluxCheck(influx.SENSORS_MISSING, "Status arrives, sensor data does not", + details=["SEN66 (air quality): no data"])) + rows = st._cloud_rows() + assert rows[0][0] == "bad" and rows[1][0] == "note" + st._engine.feed(parse_line("ESP-ROM:esp32s3-20210327")) + st._diag_dirty = True + st._refresh_diag() + texts = [st.diag.milestones.item(i).text() for i in range(st.diag.milestones.count())] + assert any(INFLUX_ROW in t for t in texts) + assert "Test box" in st.test_status.text() + st.shutdown() + app.processEvents()