Feature/influx check #3

Merged
kkallas merged 3 commits from feature/influx-check into main 2026-10-02 11:39:48 +02:00
13 changed files with 422 additions and 83 deletions
Showing only changes of commit bb933ff748 - Show all commits

No files matched your search

+15
View File
@@ -24,3 +24,18 @@
The token is never written to the session log or the service report. 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 - macOS: Connect no longer restarts the box (pyserial dropped DTR before RTS, passing through the
USB-Serial-JTAG reset state). Windows unchanged. 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.
+29
View File
@@ -1,13 +1,42 @@
import sys import sys
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from .ui.main_window import MainWindow 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: def main() -> int:
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setStyle("Fusion") app.setStyle("Fusion")
app.setPalette(light_palette())
win = MainWindow() win = MainWindow()
win.show() win.show()
return app.exec() return app.exec()
+1 -1
View File
@@ -73,7 +73,7 @@ MILESTONES = [
("wifi_connected", "Connected to Wi-Fi", False), ("wifi_connected", "Connected to Wi-Fi", False),
("time_synced", "Clock set from the Internet", False), ("time_synced", "Clock set from the Internet", False),
("clock_verified", "Clock keeps time (RTC verified)", True), ("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_registered", "Registered for firmware updates", False),
("ota_checked", "Checked for firmware updates", False), ("ota_checked", "Checked for firmware updates", False),
("ota_update_started", "Firmware update started", True), ("ota_update_started", "Firmware update started", True),
+18 -2
View File
@@ -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." 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 timeout_s = 240
poll_s = 30 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]] [[guide.step]]
id = "why" id = "why"
type = "route" 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" default = "end:sensors_missing"
[[guide.end]] [[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." text = "Status, microphone and all sensor readings of this box arrived in InfluxDB in the last 15 minutes."
actions = [] 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]] [[guide.end]]
id = "sensors_clock" id = "sensors_clock"
severity = "error" severity = "error"
+15 -1
View File
@@ -280,6 +280,20 @@ severity = "warning"
title = "InfluxDB refused some measurements (HTTP 400)" title = "InfluxDB refused some measurements (HTTP 400)"
meaning = "A measurement was malformed and dropped (firmware issue, not a hardware fault)." 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."] 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<measurement>[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]] [[rule]]
id = "influx.env_missing" 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 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).", "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]] [[rule]]
id = "kota.low_heap" id = "kota.low_heap"
+13 -4
View File
@@ -51,6 +51,7 @@ class SerialManager:
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._stop = threading.Event() self._stop = threading.Event()
self._target: _Target | None = None self._target: _Target | None = None
self._open_error = ""
self.state = State.DISCONNECTED self.state = State.DISCONNECTED
# ---- public API (any thread) ------------------------------------- # ---- public API (any thread) -------------------------------------
@@ -89,9 +90,14 @@ class SerialManager:
info = find_port(self._target.serial_number, self._target.device) info = find_port(self._target.serial_number, self._target.device)
if info is None: if info is None:
return None return None
posix = sys.platform != "win32"
# serial_for_url also accepts test URLs such as "loop://". # 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( 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 # On USB-Serial-JTAG, RTS=1/DTR=0 holds the chip in reset and DTR=1
# during reset selects download mode. # during reset selects download mode.
@@ -100,7 +106,6 @@ class SerialManager:
# macOS/Linux: the OS asserts both lines on open and pyserial then # macOS/Linux: the OS asserts both lines on open and pyserial then
# writes DTR before RTS, passing through RTS=1/DTR=0 and resetting # writes DTR before RTS, passing through RTS=1/DTR=0 and resetting
# the box on every connect. Drop RTS first, then DTR. # the box on every connect. Drop RTS first, then DTR.
posix = sys.platform != "win32"
if not posix: if not posix:
ser.dtr = False ser.dtr = False
ser.rts = False ser.rts = False
@@ -108,8 +113,12 @@ class SerialManager:
ser.open() ser.open()
if posix: if posix:
ser.dtr = False 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 return None
self._open_error = ""
self._target.device = info.device # COM number / node may change self._target.device = info.device # COM number / node may change
return ser return ser
@@ -143,7 +152,7 @@ class SerialManager:
ser = self._open() ser = self._open()
if ser is None: if ser is None:
if not announced_wait: 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 announced_wait = True
self._drain_cmds(None) self._drain_cmds(None)
time.sleep(REOPEN_POLL_S) time.sleep(REOPEN_POLL_S)
+14 -6
View File
@@ -28,6 +28,7 @@ DEFAULT_BUCKET = "sensorbox"
LOOKBACK = "-30d" LOOKBACK = "-30d"
FRESH_MIN = 15 # like SEEN_OK_MIN in the service system FRESH_MIN = 15 # like SEEN_OK_MIN in the service system
FUTURE_TOLERANCE_MIN = 10 # newer than now + this: the box clock is ahead 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 TIMEOUT_S = 20
# Sensor groups that every box must upload: name -> measurement prefixes. # 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" 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: def newest(prefixes: tuple[str, ...]) -> dt.datetime | None:
times = [t for m, t in latest.items() if m.startswith(prefixes)] times = [t for m, t in latest.items() if m.startswith(prefixes)]
return max(times) if times else None return max(times) if times else None
def fresh(t: dt.datetime | None) -> bool: 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 = [] details = []
for label, prefixes in (("Device status", (STATUS,)), ("Microphone", (MIC,)), 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) + ", ".join(missing), latest, missing, details)
if lagging: if lagging:
# Offline now, but the history already shows the fault. # Offline now, but the history already shows the fault.
return InfluxCheck(SENSORS_MISSING, f"Box is offline now (last status {_ago(now, status_t)}); " return InfluxCheck(SENSORS_MISSING, f"Nothing new in InfluxDB (last status {_ago(now, status_t)}); "
"before that its status arrived without sensor data: " + ", ".join(missing), "before that the status arrived without sensor data: " + ", ".join(missing),
latest, missing, details) 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)", return InfluxCheck(SILENT, f"No recent data in InfluxDB (nothing in the last {fresh_min} min)",
latest, missing, details) 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")) 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.""" """Blocking; run it off the UI thread. Never raises."""
mac = "".join(c for c in (mac or "") if c.isalnum()).upper() mac = "".join(c for c in (mac or "") if c.isalnum()).upper()
if cfg is None or not cfg.token: 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})") return InfluxCheck(UNAVAILABLE, f"InfluxDB not reachable from this computer ({reason})")
except (ValueError, csv.Error) as exc: except (ValueError, csv.Error) as exc:
return InfluxCheck(UNAVAILABLE, f"Unexpected InfluxDB answer ({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)
+2 -2
View File
@@ -33,14 +33,14 @@ CONNECTIVITY = "Problem z łącznością"
_STATUS_BY_RULE = [ _STATUS_BY_RULE = [
("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER), ("reboot.brownout", POWER), ("reboot.power", POWER), ("tech.power.", POWER),
("sys.crash", SOFTWARE), ("reboot.watchdog", SOFTWARE), ("sys.out_of_resources", SOFTWARE), ("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), ("rtc.init_failed", HARDWARE), ("rtc.lost_after_sync", HARDWARE), ("i2c.", HARDWARE), ("sensor.", HARDWARE),
("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE), ("sd.", HARDWARE), ("sen66.", HARDWARE), ("bme688.", HARDWARE), ("as7341.", HARDWARE),
("mic.read_fail", HARDWARE), ("mic.silent", HARDWARE), ("tech.rtc_i2c.", HARDWARE), ("tech.sensors.", 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), ("tech.mic.mic_faulty", HARDWARE), ("tech.mic.mic_switch_faulty", HARDWARE), ("tech.white.sd_", HARDWARE),
("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY), ("matter.", CONNECTIVITY), ("net.", CONNECTIVITY), ("wifi.", CONNECTIVITY), ("influx.", CONNECTIVITY),
("ota.", CONNECTIVITY), ("tech.network.", 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), ("tech.influx.never", CONNECTIVITY), ("tech.influx.", CHECK),
# firmware 1.0.16+ (KOTA: lines, status, error IDs) # firmware 1.0.16+ (KOTA: lines, status, error IDs)
("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE), ("reboot.crash", SOFTWARE), ("reboot.status_oom_watchdog", SOFTWARE), ("status.oom", SOFTWARE),
+55 -19
View File
@@ -1,9 +1,11 @@
"""Diagnostics side panel: renders a diag.engine.Report. """Diagnostics side panel: renders a diag.engine.Report.
Layout: verdict banner, expected LED (colour + pattern + meaning when the Layout: verdict banner, the test checklist (start-up milestones plus the
box reports it), firmware, the box's own self-report (KOTA lines), start-up online InfluxDB check), a findings tree (root causes; their consequences as
milestones, a findings tree (root causes; their consequences as children; children; titles only) and a wrapped detail pane for the selected finding.
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 from __future__ import annotations
@@ -17,6 +19,7 @@ from PySide6.QtWidgets import (
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QPushButton, QPushButton,
QScrollArea,
QSizePolicy, QSizePolicy,
QSplitter, QSplitter,
QTextBrowser, QTextBrowser,
@@ -43,13 +46,19 @@ BANNER = {
} }
_KEY = Qt.ItemDataRole.UserRole _KEY = Qt.ItemDataRole.UserRole
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")} 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: def _heading(text: str) -> QLabel:
lbl = QLabel(text) lbl = QLabel(text)
f = lbl.font() f = lbl.font()
f.setBold(True) f.setBold(True)
f.setPointSize(f.pointSize() + 1)
lbl.setFont(f) lbl.setFont(f)
lbl.setContentsMargins(0, 6, 0, 2)
return lbl return lbl
@@ -87,7 +96,14 @@ class DiagnosticsPanel(QWidget):
self.milestones = QListWidget() self.milestones = QListWidget()
self.milestones.setFrameShape(QFrame.Shape.NoFrame) self.milestones.setFrameShape(QFrame.Shape.NoFrame)
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection) 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.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 = QTreeWidget()
self.tree.setHeaderHidden(True) self.tree.setHeaderHidden(True)
@@ -101,22 +117,31 @@ class DiagnosticsPanel(QWidget):
findings.setSizes([220, 260]) findings.setSizes([220, 260])
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(4, 0, 0, 0) lay.setContentsMargins(4, 0, 4, 0)
lay.addWidget(self.verdict) lay.addWidget(self.verdict)
lay.addWidget(_heading("Status LED should be")) lay.addWidget(_heading("Test checklist (current boot)"))
lay.addLayout(led_row)
lay.addWidget(_heading("Firmware"))
lay.addWidget(self.firmware)
self.qr_btn = QPushButton("Matter pairing QR code...")
self.qr_btn.setToolTip("Show the box's Matter QR code on screen to scan with the phone")
lay.addWidget(self.qr_btn, 0, Qt.AlignmentFlag.AlignLeft)
lay.addWidget(self.box_title)
lay.addWidget(self.box)
lay.addWidget(_heading("Start-up progress (current boot)"))
lay.addWidget(self.milestones) lay.addWidget(self.milestones)
lay.addWidget(_heading("Findings - root causes first; click one for details")) lay.addWidget(_heading("Findings - root causes first; click one for details"))
lay.addWidget(findings, 1) 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._findings: dict[str, Finding] = {}
self._selected: str | None = None self._selected: str | None = None
# key -> expanded, chosen by the user; survives the rebuild on refresh. # 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)) 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]) bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
if r.lines == 0: if r.lines == 0:
bg, fg = "#e6e6e6", "#333333" bg, fg = "#e6e6e6", "#333333"
@@ -161,10 +187,20 @@ class DiagnosticsPanel(QWidget):
done = t is not None done = t is not None
it = QListWidgetItem(("✔ " if done else "○ ") + label it = QListWidgetItem(("✔ " if done else "○ ") + label
+ (f" ({t / 1000:.1f} s)" if done else "")) + (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) self.milestones.addItem(it)
rows = min(max(self.milestones.count(), 1), 15) for state, text in extra_rows:
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6) 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) self._fill_tree(r)
+204 -47
View File
@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
import datetime as dt
import os import os
import sys
import threading import threading
import time
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
@@ -18,12 +21,14 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QLineEdit, QLineEdit,
QMainWindow, QMainWindow,
QMenu,
QMessageBox, QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QPushButton, QPushButton,
QSplitter, QSplitter,
QTabWidget, QTabWidget,
QTextBrowser, QTextBrowser,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -49,6 +54,13 @@ from .sd_tab import SdTab
from .wifi_tab import WifiTab from .wifi_tab import WifiTab
MAX_LINES = 20000 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. # "KOTA" = the firmware's structured diagnostic lines (1.0.16+) plus errors.
LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA", LEVEL_FILTERS = {"All lines": None, "KOTA diagnostics + errors": "KOTA",
"Warnings + errors": {"W", "E"}, "Errors only": {"E"}} "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
@@ -65,6 +77,10 @@ COLORS = {
} }
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class _Bridge(QObject): class _Bridge(QObject):
"""Marshals SerialManager callbacks (reader thread) onto the UI thread.""" """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) self._line_listeners: list = [] # other tabs following the live log (Wi-Fi over USB)
# --- controls # --- controls
self.port_combo = QComboBox(minimumWidth=320) self._settings = QSettings("KOTA", "TechnicianTool")
self.all_ports = QCheckBox("Show all ports") self.port_combo = QComboBox(minimumWidth=360)
self.refresh_btn = QPushButton("Refresh") 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.connect_btn = QPushButton("Connect")
self.reset_btn = QPushButton("Reset && capture") self.connect_btn.setMinimumHeight(32)
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
# 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("<b>Box:</b>")
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 = QComboBox()
self.filter_combo.addItems(LEVEL_FILTERS) self.filter_combo.addItems(LEVEL_FILTERS)
self.clear_btn = QPushButton("Clear view") 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 = QPlainTextEdit(readOnly=True)
self.view.setMaximumBlockCount(MAX_LINES) self.view.setMaximumBlockCount(MAX_LINES)
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas") mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
mono.setStyleHint(QFont.StyleHint.Monospace) mono.setStyleHint(QFont.StyleHint.Monospace)
mono.setPointSize(10) mono.setPointSize(12 if sys.platform == "darwin" else 10)
self.view.setFont(mono) self.view.setFont(mono)
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }") 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.diag = DiagnosticsPanel()
self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload) self.guide = GuidePanel(self._runner, self._engine, matter_payload=self.matter_payload)
self.side_tabs = QTabWidget() 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.guide, "Guided troubleshooting")
self.side_tabs.addTab(self.diag.details, "Box details")
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(self.view) split.addWidget(self.diag)
split.addWidget(self.side_tabs) split.addWidget(self.side_tabs)
split.setStretchFactor(0, 3) split.setStretchFactor(0, 1)
split.setStretchFactor(1, 2) split.setStretchFactor(1, 1)
split.setSizes([700, 480]) split.setSizes([560, 620])
self.status = QLabel() self.status = QLabel()
self.status.setStyleSheet("color:#555")
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addLayout(row1) layout.addLayout(top)
layout.addLayout(row2) layout.addWidget(self.test_status)
layout.addWidget(split, 1) layout.addWidget(split, 1)
layout.addWidget(self.status) layout.addWidget(self.status)
# --- wiring # --- wiring
self.refresh_btn.clicked.connect(self.refresh_ports) self.refresh_btn.clicked.connect(self.refresh_ports)
self.all_ports.toggled.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.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.clear_btn.clicked.connect(self.view.clear)
self.save_btn.clicked.connect(self._save_view) self.save_btn.triggered.connect(self._save_view)
self.folder_btn.clicked.connect(self._open_folder) self.folder_btn.triggered.connect(self._open_folder)
self.analyze_btn.clicked.connect(self._analyze_file) self.analyze_btn.triggered.connect(self._analyze_file)
self.copy_btn.clicked.connect(self._copy_report) self.copy_btn.triggered.connect(self._copy_report)
self.service_btn.clicked.connect(lambda: self._open_service_report(auto=False)) 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()) 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. # Report refresh is throttled; lines can arrive at >100/s.
self._diag_timer = QTimer(self, interval=400) self._diag_timer = QTimer(self, interval=400)
self._diag_timer.timeout.connect(self._refresh_diag) self._diag_timer.timeout.connect(self._refresh_diag)
@@ -221,8 +282,98 @@ class SerialTab(QWidget):
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._active_port = port self._active_port = port
self._cloud, self._cloud_checking = None, False
self._manager.connect(port) 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"<b>Test running {el // 60}:{el % 60:02d}</b> - {_esc(doing)}"
elif getattr(self, "_test_end", None) is not None:
e = self._test_end
key = e.severity
text = (f"<b>Test finished: {_esc(e.title)}</b><br>{_esc(e.text)} "
"Details: Findings below and the <i>Guided troubleshooting</i> tab.")
elif getattr(self, "_test_text", ""):
key, text = "warning", f"<b>{_esc(self._test_text)}</b>"
else:
key = "idle"
text = ("Press <b>▶ Test box</b>: 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: def matter_payload(self) -> SetupPayload:
"""Pairing payload reported by this box (KOTA: matter), from its log, or the defaults.""" """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 [], 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._session.stop()
self.connect_btn.setText("Disconnect" if running else "Connect") self.connect_btn.setText("Disconnect" if running else "Connect")
self.reset_btn.setEnabled(state == State.CONNECTED) self.reset_btn.setEnabled(state == State.CONNECTED)
self.test_btn.setEnabled(state != State.WAITING)
self.port_combo.setEnabled(not running) self.port_combo.setEnabled(not running)
self.all_ports.setEnabled(not running) self.all_ports.setEnabled(not running)
self.refresh_btn.setEnabled(not running) self.refresh_btn.setEnabled(not running)
@@ -376,9 +528,11 @@ class SerialTab(QWidget):
# ---- diagnostics ------------------------------------------------- # ---- diagnostics -------------------------------------------------
def _refresh_diag(self) -> None: def _refresh_diag(self) -> None:
self._tick_test()
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._cloud_rows())
self._update_test_strip()
self.guide.refresh() # countdowns and timeouts need a steady tick self.guide.refresh() # countdowns and timeouts need a steady tick
# ---- guided troubleshooting hooks ------------------------------------ # ---- guided troubleshooting hooks ------------------------------------
@@ -392,7 +546,9 @@ class SerialTab(QWidget):
if cfg is None: if cfg is None:
self._runner.note = "InfluxDB access is not set up: Settings > InfluxDB access." self._runner.note = "InfluxDB access is not set up: Settings > InfluxDB access."
return False 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() name="influx-check", daemon=True).start()
return True return True
return False return False
@@ -409,6 +565,7 @@ class SerialTab(QWidget):
bucket=str(self._settings.value("influx/bucket", influx.DEFAULT_BUCKET))) bucket=str(self._settings.value("influx/bucket", influx.DEFAULT_BUCKET)))
def _on_influx(self, check: influx.InfluxCheck) -> None: 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._runner.influx_result(check.outcome, check.summary, check.details)
self._diag_dirty = True self._diag_dirty = True
self.guide.refresh() self.guide.refresh()
+12
View File
@@ -334,3 +334,15 @@ def test_quick_check_green_ends_with_influx_check(influx_rig):
runner.answer("GREEN") runner.answer("GREEN")
runner.answer("0") # heartbeat blinking runner.answer("0") # heartbeat blinking
assert runner.guide.id == "influx" and actions == ["influx"] 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"
+13 -1
View File
@@ -67,7 +67,7 @@ def test_offline_box_whose_sensors_stopped_before_status():
latest = {"device_status": _t(29 * 60), "microphone": _t(29 * 60), latest = {"device_status": _t(29 * 60), "microphone": _t(29 * 60),
"as7341_adc": _t(21 * 1440), "bme688": _t(21 * 1440), "sen66": _t(21 * 1440)} "as7341_adc": _t(21 * 1440), "bme688": _t(21 * 1440), "sen66": _t(21 * 1440)}
c = influx.classify(latest, NOW) 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("stopped" in d for d in c.details)
assert any("21 days ago" 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 req.get_header("Authorization") == "Token tok"
assert "tok" not in req.full_url and req.full_url.endswith("/api/v2/query?org=kota") assert "tok" not in req.full_url and req.full_url.endswith("/api/v2/query?org=kota")
assert b'"A4CB8FC9473C"' in req.data 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
+31
View File
@@ -19,3 +19,34 @@ def test_main_window_builds_with_all_tabs():
assert "InfluxDB access..." in settings assert "InfluxDB access..." in settings
w.serial_tab.shutdown() w.serial_tab.shutdown()
app.processEvents() 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()