Store all text files with LF line endings

Some files had been saved with CRLF on Windows, so a one-line change
showed as a whole-file diff. .gitattributes now stores text as LF
(.bat stays CRLF for cmd, .command/.sh LF for macOS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kkallasandClaude Opus 5.5 committed 2026-09-29 17:30:25 +02:00
1 parent 3fe45fa4d5
commit 0d21c5c205
19 files changed
+8290 -8288

No files matched your search

+87 -87
View File
@@ -1,87 +1,87 @@
"""Quick-start cheat sheet shown on launch.
LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md,
status.cpp Status::pattern_for): colour AND pattern name the fault. Older
boxes (v1.0, built Jun 29 2026) show the same colours, always solid.
"""
# (swatch colour, LED name, pattern -> meaning, first action)
LED_TABLE = [
("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."),
("#1fc8d8", "Cyan",
"breathing: not paired in Matter yet<br>fast blink: a phone is connected for pairing.",
"Pair the box in the Matter app (QR sticker or the QR code in the tool)."),
("#f4f4f4", "White",
"fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>"
"breathing: Wi-Fi setup portal.",
"SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."),
("#d64541", "Red",
"1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed<br>"
"solid: other internal error / out of memory.",
"Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."),
("#2f6fd6", "Blue",
"1 pulse: clock (RTC) not responding<br>2 pulses: I2C bus stuck (shorted / broken sensor cable).",
"Check the I2C cable between the boards first, then the RTC module."),
("#8e44ad", "Purple",
"solid: no network<br>blinking: server rejected the box (wrong ENV.TXT credentials).",
"Solid: check Wi-Fi / Internet. Blinking: re-provision the SD card with the current ENV.TXT."),
("#e6c229", "Yellow", "blinking, faster over time: downloading an update.", "Do not switch off. Wait."),
("#f08c1a", "Orange", "solid: self-check after an update (~2.5 min).",
"Wait; should turn green (or red on failure)."),
("#444444", "Off", "Firmware did not start (or crashed very early).",
"Check power switch & USB cable. If the small LED is also dark: re-flash."),
]
HINTS = [
"<b>Before a self-restart</b> the LED flashes the error colour 3 times; <b>after the restart</b> it "
"shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press "
"<i>Reset &amp; capture</i>: the tool reads the restart reason from the box.",
"<b>Small LED next to the RGB LED</b> (heartbeat): blinks once per second while online, "
"double-blinks while offline. Dark or steady = firmware hung or not running.",
"<b>Serial monitor</b>: lines starting with <b>KOTA:</b> are the box's own diagnosis (boot, I2C, SD, "
"clock, Matter, microphone, status, LED, and a health line every 60 s). The Diagnosis panel reads them "
"directly; filter the view with <i>KOTA diagnostics + errors</i>. Error lines carry a code like "
"<b>[E-SD-03]</b> - the tool shows the recommended action for it.",
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
"(off = no noise measurements), and the Matter button. Holding the "
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
"network and must be commissioned again with the sticker QR code).",
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
"Normally not needed - the tool resets the board over USB.",
"<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
"by its compile date.",
]
STEPS = [
"Switch the sensorbox <b>power switch ON</b>.",
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
"<b>Connect</b>.",
"Press <b>Reset &amp; capture</b> to record a full boot log from the first line.",
"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.",
]
def cheatsheet_html() -> str:
rows = "".join(
f"<tr><td style='padding:6px'><span style='background:{c};"
f"border:1px solid #888'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>&nbsp;<b>{name}</b></td>"
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
for c, name, meaning, action in LED_TABLE
)
steps = "".join(f"<li>{s}</li>" for s in STEPS)
hints = "".join(f"<li>{h}</li>" for h in HINTS)
return f"""
<h2>Sensorbox quick start</h2>
<ol>{steps}</ol>
<h3>RGB status LED - colour and pattern</h3>
<table border='1' cellspacing='0' cellpadding='0' width='100%'>
<tr><th align='left' style='padding:6px'>LED</th>
<th align='left' style='padding:6px'>Pattern: meaning</th>
<th align='left' style='padding:6px'>First action</th></tr>
{rows}
</table>
<h3>Good to know</h3>
<ul>{hints}</ul>
"""
"""Quick-start cheat sheet shown on launch.
LED table for firmware 1.0.16+ (firmware-diagnostics-what-changed.md,
status.cpp Status::pattern_for): colour AND pattern name the fault. Older
boxes (v1.0, built Jun 29 2026) show the same colours, always solid.
"""
# (swatch colour, LED name, pattern -> meaning, first action)
LED_TABLE = [
("#2e9e44", "Green", "solid: everything OK.", "Nothing to do."),
("#1fc8d8", "Cyan",
"breathing: not paired in Matter yet<br>fast blink: a phone is connected for pairing.",
"Pair the box in the Matter app (QR sticker or the QR code in the tool)."),
("#f4f4f4", "White",
"fast blink: SD card or ENV.TXT error<br>solid: factory reset (Matter button held 5 s)<br>"
"breathing: Wi-Fi setup portal.",
"SD card is inside the case: re-seat or re-provision it. Setup portal: short-press the button to leave."),
("#d64541", "Red",
"1-6 pulses: 1 BME688, 2 SEN66, 3 AS7341, 4 microphone, 5 flicker, 6 self-check failed<br>"
"solid: other internal error / out of memory.",
"Count the pulses, then check that sensor's cable. The box restarts itself after 150 s."),
("#2f6fd6", "Blue",
"1 pulse: clock (RTC) not responding<br>2 pulses: I2C bus stuck (shorted / broken sensor cable).",
"Check the I2C cable between the boards first, then the RTC module."),
("#8e44ad", "Purple",
"solid: no network<br>blinking: server rejected the box (wrong ENV.TXT credentials).",
"Solid: check Wi-Fi / Internet. Blinking: re-provision the SD card with the current ENV.TXT."),
("#e6c229", "Yellow", "blinking, faster over time: downloading an update.", "Do not switch off. Wait."),
("#f08c1a", "Orange", "solid: self-check after an update (~2.5 min).",
"Wait; should turn green (or red on failure)."),
("#444444", "Off", "Firmware did not start (or crashed very early).",
"Check power switch & USB cable. If the small LED is also dark: re-flash."),
]
HINTS = [
"<b>Before a self-restart</b> the LED flashes the error colour 3 times; <b>after the restart</b> it "
"shows the previous error for 3 s. So a reboot loop shows its cause. Connect serial and press "
"<i>Reset &amp; capture</i>: the tool reads the restart reason from the box.",
"<b>Small LED next to the RGB LED</b> (heartbeat): blinks once per second while online, "
"double-blinks while offline. Dark or steady = firmware hung or not running.",
"<b>Serial monitor</b>: lines starting with <b>KOTA:</b> are the box's own diagnosis (boot, I2C, SD, "
"clock, Matter, microphone, status, LED, and a health line every 60 s). The Diagnosis panel reads them "
"directly; filter the view with <i>KOTA diagnostics + errors</i>. Error lines carry a code like "
"<b>[E-SD-03]</b> - the tool shows the recommended action for it.",
"<b>Outside the case</b> (back): power switch, the <b>microphone switch</b> just above it "
"(off = no noise measurements), and the Matter button. Holding the "
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
"network and must be commissioned again with the sticker QR code).",
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
"Normally not needed - the tool resets the board over USB.",
"<b>Flashing</b> uses only the firmware files in the tool's <b>firmware</b> folder; choose the build "
"by its compile date.",
]
STEPS = [
"Switch the sensorbox <b>power switch ON</b>.",
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
"<b>Connect</b>.",
"Press <b>Reset &amp; capture</b> to record a full boot log from the first line.",
"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.",
]
def cheatsheet_html() -> str:
rows = "".join(
f"<tr><td style='padding:6px'><span style='background:{c};"
f"border:1px solid #888'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>&nbsp;<b>{name}</b></td>"
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
for c, name, meaning, action in LED_TABLE
)
steps = "".join(f"<li>{s}</li>" for s in STEPS)
hints = "".join(f"<li>{h}</li>" for h in HINTS)
return f"""
<h2>Sensorbox quick start</h2>
<ol>{steps}</ol>
<h3>RGB status LED - colour and pattern</h3>
<table border='1' cellspacing='0' cellpadding='0' width='100%'>
<tr><th align='left' style='padding:6px'>LED</th>
<th align='left' style='padding:6px'>Pattern: meaning</th>
<th align='left' style='padding:6px'>First action</th></tr>
{rows}
</table>
<h3>Good to know</h3>
<ul>{hints}</ul>
"""
+252 -252
View File
@@ -1,252 +1,252 @@
"""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.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QSizePolicy,
QSplitter,
QTextBrowser,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from ..diag.engine import LED, Finding, Report, Severity
SEV_COLOR = {
Severity.CRITICAL: "#b03a2e",
Severity.ERROR: "#d9534f",
Severity.WARNING: "#c77c02",
Severity.INFO: "#2e6da4",
}
# verdict banner (background, text) by verdict severity; None = healthy
BANNER = {
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
Severity.ERROR: ("#f9dedc", "#8a2019"),
Severity.WARNING: ("#fdebc8", "#6b4400"),
None: ("#d8f0dd", "#1b5e2a"),
}
_KEY = Qt.ItemDataRole.UserRole
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
def _heading(text: str) -> QLabel:
lbl = QLabel(text)
f = lbl.font()
f.setBold(True)
lbl.setFont(f)
return lbl
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class DiagnosticsPanel(QWidget):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setMinimumWidth(380)
self.verdict = QLabel(wordWrap=True)
self.verdict.setMargin(10)
vf = self.verdict.font()
vf.setPointSize(vf.pointSize() + 2)
vf.setBold(True)
self.verdict.setFont(vf)
self.led_swatch = QLabel()
self.led_swatch.setFixedSize(22, 22)
self.led_text = QLabel(wordWrap=True)
led_row = QHBoxLayout()
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
led_row.addWidget(self.led_text, 1)
self.firmware = QLabel(wordWrap=True)
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.box_title = _heading("Box self-report (KOTA lines)")
self.box = QLabel(wordWrap=True)
self.box.setTextFormat(Qt.TextFormat.RichText)
self.box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.milestones = QListWidget()
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.setIndentation(16)
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
self.detail = QTextBrowser()
self.detail.setOpenExternalLinks(False)
findings = QSplitter(Qt.Orientation.Vertical)
findings.addWidget(self.tree)
findings.addWidget(self.detail)
findings.setSizes([220, 260])
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 0, 0, 0)
lay.addWidget(self.verdict)
lay.addWidget(_heading("Status LED should be"))
lay.addLayout(led_row)
lay.addWidget(_heading("Firmware"))
lay.addWidget(self.firmware)
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(_heading("Findings - root causes first; click one for details"))
lay.addWidget(findings, 1)
self._findings: dict[str, Finding] = {}
self._selected: str | None = None
# key -> expanded, chosen by the user; survives the rebuild on refresh.
self._expanded: dict[str, bool] = {}
self.tree.currentItemChanged.connect(self._on_select)
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
# ------------------------------------------------------------------
def show_report(self, r: Report) -> None:
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
if r.lines == 0:
bg, fg = "#e6e6e6", "#333333"
self.verdict.setText(r.verdict)
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
name, colour = LED[r.led]
self.led_swatch.setStyleSheet(
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else ""
meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning else ""
source = " <small style='color:#1b7a33'>(reported by the box)</small>" if r.led_exact else ""
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
self.led_text.setText(f"<b>{name}{pattern}</b>{meaning}{source}{ask}{reason}")
fw = r.firmware
sd = {True: "reads Wi-Fi settings from the SD card",
False: "does not read Wi-Fi settings from the SD card",
None: "Wi-Fi-from-SD support unknown"}
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
self.box_title.setVisible(bool(r.box))
self.box.setVisible(bool(r.box))
self.box.setText("<table cellspacing='0' cellpadding='1'>" + "".join(
f"<tr><td style='color:{BOX_STATE[st][0]}'><b>{BOX_STATE[st][1]}</b>&nbsp;</td>"
f"<td><b>{_esc(topic)}</b>&nbsp;</td><td>{_esc(text)}</td></tr>"
for topic, text, st in r.box) + "</table>")
self.milestones.clear()
for _, label, t in r.milestones:
done = t is not None
it = QListWidgetItem(("✔ " if done else "○ ") + label
+ (f" ({t / 1000:.1f} s)" if done else ""))
it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a")))
self.milestones.addItem(it)
rows = min(max(self.milestones.count(), 1), 15)
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
self._fill_tree(r)
def _fill_tree(self, r: Report) -> None:
self.tree.blockSignals(True)
self.tree.clear()
self._findings = {}
if not r.roots:
it = QTreeWidgetItem(["No problems detected in the log so far."])
it.setForeground(0, QBrush(QColor("#1b7a33")))
self.tree.addTopLevelItem(it)
for rc in r.roots:
top = self._item(rc.finding, bold=True)
self.tree.addTopLevelItem(top)
for c in rc.consequences:
top.addChild(self._item(c, prefix="↳ "))
top.setExpanded(self._expanded.get(rc.finding.key, True))
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
("Resolved during this session", "grp:resolved", r.resolved)):
if not items:
continue
grp = QTreeWidgetItem([f"{title} ({len(items)})"])
grp.setData(0, _KEY, key)
grp.setForeground(0, QBrush(QColor("#666666")))
self.tree.addTopLevelItem(grp)
for f in items:
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
grp.setExpanded(self._expanded.get(key, False))
# Keep the selection across refreshes; default to the top root cause.
target = self._selected if self._selected in self._findings else (
r.roots[0].finding.key if r.roots else None)
self.tree.blockSignals(False)
self._select(target)
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
if key := item.data(0, _KEY):
self._expanded[key] = expanded
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
it.setData(0, _KEY, f.key)
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
it.setToolTip(0, f.title)
if bold:
font = it.font(0)
font.setBold(True)
it.setFont(0, font)
self._findings[f.key] = f
return it
def _select(self, key: str | None) -> None:
if key is None:
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
return
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
for it in match:
if it.data(0, _KEY) == key:
self.tree.setCurrentItem(it)
return
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
key = cur.data(0, _KEY) if cur else None
f = self._findings.get(key or "")
if f is None:
return
self._selected = key
self.detail.setHtml(_detail_html(f))
def _detail_html(f: Finding) -> str:
colour = SEV_COLOR.get(f.severity, "#333")
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
seen = _esc(f.seen_text())
evidence = "<br>".join(_esc(e) for e in f.evidence)
codes = "".join(f"<li><b>{_esc(eid)}</b>" + (f" - {_esc(a)}" if a else "") + "</li>"
for eid, a in f.error_ids.items())
return (
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
+ (f"<p><b>Firmware error codes</b> (error_ids.csv):</p><ul>{codes}</ul>" if codes else "")
+ f"<p style='color:#666'>{seen}</p>"
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
if evidence else "")
)
"""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.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QSizePolicy,
QSplitter,
QTextBrowser,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from ..diag.engine import LED, Finding, Report, Severity
SEV_COLOR = {
Severity.CRITICAL: "#b03a2e",
Severity.ERROR: "#d9534f",
Severity.WARNING: "#c77c02",
Severity.INFO: "#2e6da4",
}
# verdict banner (background, text) by verdict severity; None = healthy
BANNER = {
Severity.CRITICAL: ("#f6d5d1", "#7b1e14"),
Severity.ERROR: ("#f9dedc", "#8a2019"),
Severity.WARNING: ("#fdebc8", "#6b4400"),
None: ("#d8f0dd", "#1b5e2a"),
}
_KEY = Qt.ItemDataRole.UserRole
BOX_STATE = {"ok": ("#1b7a33", "✔"), "warn": ("#c77c02", "!"), "bad": ("#b03a2e", "✖"), "info": ("#2e6da4", "i")}
def _heading(text: str) -> QLabel:
lbl = QLabel(text)
f = lbl.font()
f.setBold(True)
lbl.setFont(f)
return lbl
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class DiagnosticsPanel(QWidget):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setMinimumWidth(380)
self.verdict = QLabel(wordWrap=True)
self.verdict.setMargin(10)
vf = self.verdict.font()
vf.setPointSize(vf.pointSize() + 2)
vf.setBold(True)
self.verdict.setFont(vf)
self.led_swatch = QLabel()
self.led_swatch.setFixedSize(22, 22)
self.led_text = QLabel(wordWrap=True)
led_row = QHBoxLayout()
led_row.addWidget(self.led_swatch, 0, Qt.AlignmentFlag.AlignTop)
led_row.addWidget(self.led_text, 1)
self.firmware = QLabel(wordWrap=True)
self.firmware.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.box_title = _heading("Box self-report (KOTA lines)")
self.box = QLabel(wordWrap=True)
self.box.setTextFormat(Qt.TextFormat.RichText)
self.box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.milestones = QListWidget()
self.milestones.setFrameShape(QFrame.Shape.NoFrame)
self.milestones.setSelectionMode(QListWidget.SelectionMode.NoSelection)
self.milestones.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.setIndentation(16)
self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
self.detail = QTextBrowser()
self.detail.setOpenExternalLinks(False)
findings = QSplitter(Qt.Orientation.Vertical)
findings.addWidget(self.tree)
findings.addWidget(self.detail)
findings.setSizes([220, 260])
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 0, 0, 0)
lay.addWidget(self.verdict)
lay.addWidget(_heading("Status LED should be"))
lay.addLayout(led_row)
lay.addWidget(_heading("Firmware"))
lay.addWidget(self.firmware)
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(_heading("Findings - root causes first; click one for details"))
lay.addWidget(findings, 1)
self._findings: dict[str, Finding] = {}
self._selected: str | None = None
# key -> expanded, chosen by the user; survives the rebuild on refresh.
self._expanded: dict[str, bool] = {}
self.tree.currentItemChanged.connect(self._on_select)
self.tree.itemCollapsed.connect(lambda it: self._remember(it, False))
self.tree.itemExpanded.connect(lambda it: self._remember(it, True))
# ------------------------------------------------------------------
def show_report(self, r: Report) -> None:
bg, fg = BANNER.get(r.verdict_severity, BANNER[Severity.WARNING])
if r.lines == 0:
bg, fg = "#e6e6e6", "#333333"
self.verdict.setText(r.verdict)
self.verdict.setStyleSheet(f"QLabel {{ background:{bg}; color:{fg}; border-radius:6px; }}")
name, colour = LED[r.led]
self.led_swatch.setStyleSheet(
f"QLabel {{ background:{colour}; border:1px solid #777; border-radius:11px; }}")
ask = "" if r.led == "UNKNOWN" else " - <i>check it matches the box</i>"
pattern = f", {_esc(r.led_pattern)}" if r.led_pattern else ""
meaning = f" = {_esc(r.led_meaning)}" if r.led_meaning else ""
source = " <small style='color:#1b7a33'>(reported by the box)</small>" if r.led_exact else ""
reason = f"<br><small style='color:#666'>from: {_esc(r.led_reason)}</small>" if r.led_reason else ""
self.led_text.setText(f"<b>{name}{pattern}</b>{meaning}{source}{ask}{reason}")
fw = r.firmware
sd = {True: "reads Wi-Fi settings from the SD card",
False: "does not read Wi-Fi settings from the SD card",
None: "Wi-Fi-from-SD support unknown"}
self.firmware.setText(f"{_esc(fw.describe())}<br><small>{sd[fw.supports_sd_wifi]}</small>")
self.box_title.setVisible(bool(r.box))
self.box.setVisible(bool(r.box))
self.box.setText("<table cellspacing='0' cellpadding='1'>" + "".join(
f"<tr><td style='color:{BOX_STATE[st][0]}'><b>{BOX_STATE[st][1]}</b>&nbsp;</td>"
f"<td><b>{_esc(topic)}</b>&nbsp;</td><td>{_esc(text)}</td></tr>"
for topic, text, st in r.box) + "</table>")
self.milestones.clear()
for _, label, t in r.milestones:
done = t is not None
it = QListWidgetItem(("✔ " if done else "○ ") + label
+ (f" ({t / 1000:.1f} s)" if done else ""))
it.setForeground(QBrush(QColor("#1b7a33" if done else "#8a8a8a")))
self.milestones.addItem(it)
rows = min(max(self.milestones.count(), 1), 15)
self.milestones.setFixedHeight(rows * max(self.milestones.sizeHintForRow(0), 16) + 6)
self._fill_tree(r)
def _fill_tree(self, r: Report) -> None:
self.tree.blockSignals(True)
self.tree.clear()
self._findings = {}
if not r.roots:
it = QTreeWidgetItem(["No problems detected in the log so far."])
it.setForeground(0, QBrush(QColor("#1b7a33")))
self.tree.addTopLevelItem(it)
for rc in r.roots:
top = self._item(rc.finding, bold=True)
self.tree.addTopLevelItem(top)
for c in rc.consequences:
top.addChild(self._item(c, prefix="↳ "))
top.setExpanded(self._expanded.get(rc.finding.key, True))
for title, key, items in (("Seen in earlier boots only", "grp:earlier", r.earlier),
("Resolved during this session", "grp:resolved", r.resolved)):
if not items:
continue
grp = QTreeWidgetItem([f"{title} ({len(items)})"])
grp.setData(0, _KEY, key)
grp.setForeground(0, QBrush(QColor("#666666")))
self.tree.addTopLevelItem(grp)
for f in items:
grp.addChild(self._item(f, prefix="✔ " if key == "grp:resolved" else ""))
grp.setExpanded(self._expanded.get(key, False))
# Keep the selection across refreshes; default to the top root cause.
target = self._selected if self._selected in self._findings else (
r.roots[0].finding.key if r.roots else None)
self.tree.blockSignals(False)
self._select(target)
def _remember(self, item: QTreeWidgetItem, expanded: bool) -> None:
if key := item.data(0, _KEY):
self._expanded[key] = expanded
def _item(self, f: Finding, prefix: str = "", bold: bool = False) -> QTreeWidgetItem:
it = QTreeWidgetItem([f"{prefix}[{f.severity.label}] {f.title}"])
it.setData(0, _KEY, f.key)
it.setForeground(0, QBrush(QColor(SEV_COLOR.get(f.severity, "#333333"))))
it.setToolTip(0, f.title)
if bold:
font = it.font(0)
font.setBold(True)
it.setFont(0, font)
self._findings[f.key] = f
return it
def _select(self, key: str | None) -> None:
if key is None:
self.detail.setHtml("<p style='color:#666'>Nothing to show yet.</p>")
return
match = self.tree.findItems("*", Qt.MatchFlag.MatchWildcard | Qt.MatchFlag.MatchRecursive)
for it in match:
if it.data(0, _KEY) == key:
self.tree.setCurrentItem(it)
return
def _on_select(self, cur: QTreeWidgetItem | None, _prev) -> None:
key = cur.data(0, _KEY) if cur else None
f = self._findings.get(key or "")
if f is None:
return
self._selected = key
self.detail.setHtml(_detail_html(f))
def _detail_html(f: Finding) -> str:
colour = SEV_COLOR.get(f.severity, "#333")
actions = "".join(f"<li>{_esc(a)}</li>" for a in f.actions)
seen = _esc(f.seen_text())
evidence = "<br>".join(_esc(e) for e in f.evidence)
codes = "".join(f"<li><b>{_esc(eid)}</b>" + (f" - {_esc(a)}" if a else "") + "</li>"
for eid, a in f.error_ids.items())
return (
f"<h3 style='color:{colour}; margin:0'>{_esc(f.title)}</h3>"
f"<p><b>What it means:</b> {_esc(f.meaning)}</p>"
+ (f"<p><b>What to do:</b></p><ol>{actions}</ol>" if actions else "")
+ (f"<p><b>Firmware error codes</b> (error_ids.csv):</p><ul>{codes}</ul>" if codes else "")
+ f"<p style='color:#666'>{seen}</p>"
+ (f"<p style='color:#555; font-family:Consolas,Menlo,monospace; font-size:9pt'>{evidence}</p>"
if evidence else "")
)
+364 -364
View File
@@ -1,364 +1,364 @@
"""Flash firmware tab: only the .bin files in the tool's firmware/ folder,
chosen by their compile date; App-only by default."""
from __future__ import annotations
import datetime as dt
import tempfile
from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
from ..core.session import SESSIONS_DIR
from ..diag.firmware import local_build, register_local_build, same_build
from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_images
from ..flash.plan import Mode, build_plan
from ..flash.runner import FlashRunner, Result
from ..serialio.ports import PortInfo, list_serial_ports
BACKUP_DIR = SESSIONS_DIR.parent / "backups"
VERIFY_TIMEOUT_MS = 45_000
HELP = (
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
)
class _Bridge(QObject):
step = Signal(int, str)
progress = Signal(int)
output = Signal(str)
done = Signal(object)
class FlashTab(QWidget):
def __init__(self, serial_tab, parent: QWidget | None = None):
super().__init__(parent)
self.serial_tab = serial_tab
self._images: list[FirmwareImage] = []
self._ports: list[PortInfo] = [] # before any signal can call _update()
self._runner: FlashRunner | None = None
self._port: PortInfo | None = None
self._image: FirmwareImage | None = None
# 1. box
box = QGroupBox("1. Sensorbox")
self.port_combo = QComboBox(minimumWidth=360)
refresh = QPushButton("Refresh")
refresh.clicked.connect(self.refresh_ports)
self.box_fw = QLabel(wordWrap=True)
bl = QVBoxLayout(box)
row = QHBoxLayout()
row.addWidget(self.port_combo)
row.addWidget(refresh)
row.addStretch(1)
bl.addLayout(row)
bl.addWidget(self.box_fw)
# 2. firmware - only files in the tool's firmware/ folder
fwbox = QGroupBox("2. Firmware (by compile date)")
self.image_combo = QComboBox(minimumWidth=360)
self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
self.reload_btn = QPushButton("Refresh list")
self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)")
self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
self.folder_btn = QPushButton("Open firmware folder")
self.folder_btn.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
self.image_info = QLabel(wordWrap=True)
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
fl = QVBoxLayout(fwbox)
fl.addWidget(self.image_combo)
brow = QHBoxLayout()
brow.addWidget(self.reload_btn)
brow.addWidget(self.folder_btn)
brow.addStretch(1)
fl.addLayout(brow)
fl.addWidget(self.image_info)
self._problems: list[str] = []
# 3. mode
modebox = QGroupBox("3. What to write")
self.rb_app = QRadioButton("App only (recommended for repairs)")
self.rb_full = QRadioButton("Full image (new or completely wiped boards)")
self.mode_group = QButtonGroup(self)
self.mode_group.addButton(self.rb_app)
self.mode_group.addButton(self.rb_full)
self.rb_app.setChecked(True)
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
"settings stored on the box.", wordWrap=True)
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
"registration</b> - the box must be paired again afterwards.", wordWrap=True)
for n in (app_note, full_note):
n.setContentsMargins(24, 0, 0, 6)
n.setStyleSheet("color:#555")
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
ml = QVBoxLayout(modebox)
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
ml.addWidget(w)
# 4. run
runbox = QGroupBox("4. Flash")
self.plan_label = QLabel(wordWrap=True)
self.start_btn = QPushButton("Start flashing")
self.start_btn.setMinimumHeight(36)
self.start_btn.clicked.connect(self._start)
self.step_label = QLabel()
self.bar = QProgressBar()
self.result = QLabel(wordWrap=True)
self.result.setMargin(8)
self.result.setTextFormat(Qt.TextFormat.RichText)
self.details_btn = QPushButton("Show esptool output")
self.details_btn.setCheckable(True)
self.output = QPlainTextEdit(readOnly=True)
self.output.setVisible(False)
self.details_btn.toggled.connect(self.output.setVisible)
rl = QVBoxLayout(runbox)
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
rl.addWidget(w)
help_lbl = QLabel(HELP, wordWrap=True)
help_lbl.setStyleSheet("color:#555")
rl.addWidget(help_lbl)
rl.addStretch(1) # keep the run column packed at the top
left = QVBoxLayout()
for w in (box, fwbox, modebox):
left.addWidget(w)
left.addStretch(1)
lay = QHBoxLayout(self)
lay.addLayout(left, 1)
lay.addWidget(runbox, 1)
self._bridge = _Bridge()
self._bridge.step.connect(self._on_step)
self._bridge.progress.connect(self.bar.setValue)
self._bridge.output.connect(self.output.appendPlainText)
self._bridge.done.connect(self._on_done)
self._verify_timer = QTimer(self, interval=500)
self._verify_timer.timeout.connect(self._check_verify)
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
sig.connect(self._update)
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
self.image_combo.currentIndexChanged.connect(self._update)
self.port_combo.currentIndexChanged.connect(self._update)
self._load_images()
self.refresh_ports()
self._show_result("", None)
# ------------------------------------------------------------ setup
def _load_images(self, select_sha: str | None = None) -> None:
self._images, self._problems = folder_images()
for img in self._images: # lets the Serial monitor show a box build's compile date
register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
self.image_combo.blockSignals(True)
self.image_combo.clear()
for img in self._images:
self.image_combo.addItem(img.label())
if not self._images:
self.image_combo.addItem("No firmware file in the firmware folder")
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0)
self.image_combo.setCurrentIndex(idx) # default: newest compile date
self.image_combo.blockSignals(False)
self._update()
def _selected_sha(self) -> str | None:
img = self._selected_image()
return img.sha256 if img else None
def refresh_ports(self) -> None:
current = self.port_combo.currentData()
self._ports = list_serial_ports()
self.port_combo.clear()
for p in self._ports:
self.port_combo.addItem(p.label, p.device)
if not self._ports:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
self._update()
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
self.refresh_ports()
super().showEvent(event)
# ------------------------------------------------------------ state
def _selected_image(self) -> FirmwareImage | None:
i = self.image_combo.currentIndex()
return self._images[i] if 0 <= i < len(self._images) else None
def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData()
return next((p for p in self._ports if p.device == dev), None)
def _update(self) -> None:
img = self._selected_image()
info = ""
if img:
a = img.app
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
info = (f"<b>Compiled {a.built}</b> &middot; {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
f"<small>file firmware/{img.name} &middot; build {a.elf_sha[:8]} &middot; "
f"SHA-256 {img.sha256[:16]}... &middot; checksum OK</small>")
else:
info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>"
"and press <b>Refresh list</b>. Only files in this folder can be flashed.")
if self._problems:
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
+ "; ".join(self._problems) + "</span>")
self.image_info.setText(info)
# An app-only file has no bootloader/layout: Full mode is impossible.
full_ok = bool(img and img.kind == "merged")
self.rb_full.setEnabled(full_ok and self._runner is None)
if not full_ok and self.rb_full.isChecked():
self.rb_app.setChecked(True)
self.box_fw.setText(self._box_text(img))
self.confirm_full.setVisible(self.rb_full.isChecked())
ok = bool(img and self._selected_port() and self._runner is None
and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
self.start_btn.setEnabled(ok)
if img and self._selected_port():
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
if mode == Mode.APP_ONLY:
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
else:
steps += ["Write the complete image (~25 s)"]
steps += ["Restart the box and verify the new firmware boots"]
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
else:
self.plan_label.setText("Select a sensorbox and a firmware image.")
def _box_text(self, img: FirmwareImage | None) -> str:
fw = self.serial_tab.engine.firmware
if not fw.elf_sha:
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
msg = f"Box currently runs: {fw.describe()}"
if not img:
return msg
if same_build(fw.elf_sha, img.app.elf_sha):
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
local = local_build(fw.elf_sha)
box_built, img_built = _parse_built(fw.built or (local[1] if local else None)), img.app.built_at
if box_built and img_built and img_built > box_built:
return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>"
if box_built and img_built and img_built < box_built:
return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was "
"probably updated over OTA). The box updates itself again once online.</b>")
return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>"
# ------------------------------------------------------------ flash
def _start(self) -> None:
img, port = self._selected_image(), self._selected_port()
if not img or not port:
return
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
else "the COMPLETE image (pairing and OTA registration are erased)")
if QMessageBox.question(self, "Flash firmware",
f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\n"
f"File: firmware/{img.name}\nBox: {port.label}\n\n"
"Keep the box powered and the USB cable connected until it finishes.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
) != QMessageBox.StandardButton.Ok:
return
backup_to = None
if self.backup.isChecked():
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
self._port, self._image = port, img
self.serial_tab.release_port() # esptool needs the port exclusively
self.output.clear()
self.output.appendPlainText("\n".join(plan.summary()))
self._show_result("", None)
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
self._bridge.output.emit, self._bridge.done.emit)
self._set_busy(True)
self._runner.start()
def _set_busy(self, busy: bool) -> None:
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full,
self.reload_btn):
w.setEnabled(not busy)
self._update()
def _on_step(self, i: int, title: str) -> None:
n = len(self._runner.plan.steps) if self._runner else 0
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
def _on_done(self, res: Result) -> None:
self._runner = None
if not res.ok:
self._set_busy(False)
self.step_label.setText("Stopped.")
hint = f"<p>{res.hint}</p>" if res.hint else ""
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
self.details_btn.setChecked(True)
return
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
self.bar.setValue(0)
self._show_result("Firmware written. Waiting for the box to start...", "info")
self.serial_tab.connect_port(self._port, capture=True)
self._verify_ms = 0
self._verify_timer.start()
def _check_verify(self) -> None:
self._verify_ms += self._verify_timer.interval()
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
fw = self.serial_tab.engine.firmware
want = self._image.app.elf_sha[:8]
done = same_build(fw.elf_sha, self._image.app.elf_sha)
if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
self._verify_timer.stop()
self._set_busy(False)
self.bar.setValue(100)
if done:
self.step_label.setText("Done.")
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
"Check the Diagnosis panel in the Serial monitor.", "ok")
else:
seen = fw.elf_sha[:8] if fw.elf_sha else "no boot seen"
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
"Open the Serial monitor and press Reset & capture.", "warning")
def _show_result(self, html: str, kind: str | None) -> None:
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
self.result.setVisible(bool(html))
if html:
border, bg = colours[kind or "info"]
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html)
def _parse_built(s: str | None) -> dt.datetime | None:
"""'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
if not s:
return None
try:
return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
except ValueError:
return None
"""Flash firmware tab: only the .bin files in the tool's firmware/ folder,
chosen by their compile date; App-only by default."""
from __future__ import annotations
import datetime as dt
import tempfile
from pathlib import Path
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
from ..core.session import SESSIONS_DIR
from ..diag.firmware import local_build, register_local_build, same_build
from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_images
from ..flash.plan import Mode, build_plan
from ..flash.runner import FlashRunner, Result
from ..serialio.ports import PortInfo, list_serial_ports
BACKUP_DIR = SESSIONS_DIR.parent / "backups"
VERIFY_TIMEOUT_MS = 45_000
HELP = (
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
)
class _Bridge(QObject):
step = Signal(int, str)
progress = Signal(int)
output = Signal(str)
done = Signal(object)
class FlashTab(QWidget):
def __init__(self, serial_tab, parent: QWidget | None = None):
super().__init__(parent)
self.serial_tab = serial_tab
self._images: list[FirmwareImage] = []
self._ports: list[PortInfo] = [] # before any signal can call _update()
self._runner: FlashRunner | None = None
self._port: PortInfo | None = None
self._image: FirmwareImage | None = None
# 1. box
box = QGroupBox("1. Sensorbox")
self.port_combo = QComboBox(minimumWidth=360)
refresh = QPushButton("Refresh")
refresh.clicked.connect(self.refresh_ports)
self.box_fw = QLabel(wordWrap=True)
bl = QVBoxLayout(box)
row = QHBoxLayout()
row.addWidget(self.port_combo)
row.addWidget(refresh)
row.addStretch(1)
bl.addLayout(row)
bl.addWidget(self.box_fw)
# 2. firmware - only files in the tool's firmware/ folder
fwbox = QGroupBox("2. Firmware (by compile date)")
self.image_combo = QComboBox(minimumWidth=360)
self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
self.reload_btn = QPushButton("Refresh list")
self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)")
self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
self.folder_btn = QPushButton("Open firmware folder")
self.folder_btn.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
self.image_info = QLabel(wordWrap=True)
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
fl = QVBoxLayout(fwbox)
fl.addWidget(self.image_combo)
brow = QHBoxLayout()
brow.addWidget(self.reload_btn)
brow.addWidget(self.folder_btn)
brow.addStretch(1)
fl.addLayout(brow)
fl.addWidget(self.image_info)
self._problems: list[str] = []
# 3. mode
modebox = QGroupBox("3. What to write")
self.rb_app = QRadioButton("App only (recommended for repairs)")
self.rb_full = QRadioButton("Full image (new or completely wiped boards)")
self.mode_group = QButtonGroup(self)
self.mode_group.addButton(self.rb_app)
self.mode_group.addButton(self.rb_full)
self.rb_app.setChecked(True)
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
"settings stored on the box.", wordWrap=True)
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
"registration</b> - the box must be paired again afterwards.", wordWrap=True)
for n in (app_note, full_note):
n.setContentsMargins(24, 0, 0, 6)
n.setStyleSheet("color:#555")
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
ml = QVBoxLayout(modebox)
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
ml.addWidget(w)
# 4. run
runbox = QGroupBox("4. Flash")
self.plan_label = QLabel(wordWrap=True)
self.start_btn = QPushButton("Start flashing")
self.start_btn.setMinimumHeight(36)
self.start_btn.clicked.connect(self._start)
self.step_label = QLabel()
self.bar = QProgressBar()
self.result = QLabel(wordWrap=True)
self.result.setMargin(8)
self.result.setTextFormat(Qt.TextFormat.RichText)
self.details_btn = QPushButton("Show esptool output")
self.details_btn.setCheckable(True)
self.output = QPlainTextEdit(readOnly=True)
self.output.setVisible(False)
self.details_btn.toggled.connect(self.output.setVisible)
rl = QVBoxLayout(runbox)
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
rl.addWidget(w)
help_lbl = QLabel(HELP, wordWrap=True)
help_lbl.setStyleSheet("color:#555")
rl.addWidget(help_lbl)
rl.addStretch(1) # keep the run column packed at the top
left = QVBoxLayout()
for w in (box, fwbox, modebox):
left.addWidget(w)
left.addStretch(1)
lay = QHBoxLayout(self)
lay.addLayout(left, 1)
lay.addWidget(runbox, 1)
self._bridge = _Bridge()
self._bridge.step.connect(self._on_step)
self._bridge.progress.connect(self.bar.setValue)
self._bridge.output.connect(self.output.appendPlainText)
self._bridge.done.connect(self._on_done)
self._verify_timer = QTimer(self, interval=500)
self._verify_timer.timeout.connect(self._check_verify)
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
sig.connect(self._update)
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
self.image_combo.currentIndexChanged.connect(self._update)
self.port_combo.currentIndexChanged.connect(self._update)
self._load_images()
self.refresh_ports()
self._show_result("", None)
# ------------------------------------------------------------ setup
def _load_images(self, select_sha: str | None = None) -> None:
self._images, self._problems = folder_images()
for img in self._images: # lets the Serial monitor show a box build's compile date
register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
self.image_combo.blockSignals(True)
self.image_combo.clear()
for img in self._images:
self.image_combo.addItem(img.label())
if not self._images:
self.image_combo.addItem("No firmware file in the firmware folder")
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0)
self.image_combo.setCurrentIndex(idx) # default: newest compile date
self.image_combo.blockSignals(False)
self._update()
def _selected_sha(self) -> str | None:
img = self._selected_image()
return img.sha256 if img else None
def refresh_ports(self) -> None:
current = self.port_combo.currentData()
self._ports = list_serial_ports()
self.port_combo.clear()
for p in self._ports:
self.port_combo.addItem(p.label, p.device)
if not self._ports:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
self._update()
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
self.refresh_ports()
super().showEvent(event)
# ------------------------------------------------------------ state
def _selected_image(self) -> FirmwareImage | None:
i = self.image_combo.currentIndex()
return self._images[i] if 0 <= i < len(self._images) else None
def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData()
return next((p for p in self._ports if p.device == dev), None)
def _update(self) -> None:
img = self._selected_image()
info = ""
if img:
a = img.app
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
info = (f"<b>Compiled {a.built}</b> &middot; {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
f"<small>file firmware/{img.name} &middot; build {a.elf_sha[:8]} &middot; "
f"SHA-256 {img.sha256[:16]}... &middot; checksum OK</small>")
else:
info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>"
"and press <b>Refresh list</b>. Only files in this folder can be flashed.")
if self._problems:
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
+ "; ".join(self._problems) + "</span>")
self.image_info.setText(info)
# An app-only file has no bootloader/layout: Full mode is impossible.
full_ok = bool(img and img.kind == "merged")
self.rb_full.setEnabled(full_ok and self._runner is None)
if not full_ok and self.rb_full.isChecked():
self.rb_app.setChecked(True)
self.box_fw.setText(self._box_text(img))
self.confirm_full.setVisible(self.rb_full.isChecked())
ok = bool(img and self._selected_port() and self._runner is None
and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
self.start_btn.setEnabled(ok)
if img and self._selected_port():
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
if mode == Mode.APP_ONLY:
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
else:
steps += ["Write the complete image (~25 s)"]
steps += ["Restart the box and verify the new firmware boots"]
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
else:
self.plan_label.setText("Select a sensorbox and a firmware image.")
def _box_text(self, img: FirmwareImage | None) -> str:
fw = self.serial_tab.engine.firmware
if not fw.elf_sha:
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
msg = f"Box currently runs: {fw.describe()}"
if not img:
return msg
if same_build(fw.elf_sha, img.app.elf_sha):
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
local = local_build(fw.elf_sha)
box_built, img_built = _parse_built(fw.built or (local[1] if local else None)), img.app.built_at
if box_built and img_built and img_built > box_built:
return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>"
if box_built and img_built and img_built < box_built:
return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was "
"probably updated over OTA). The box updates itself again once online.</b>")
return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>"
# ------------------------------------------------------------ flash
def _start(self) -> None:
img, port = self._selected_image(), self._selected_port()
if not img or not port:
return
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
else "the COMPLETE image (pairing and OTA registration are erased)")
if QMessageBox.question(self, "Flash firmware",
f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\n"
f"File: firmware/{img.name}\nBox: {port.label}\n\n"
"Keep the box powered and the USB cable connected until it finishes.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
) != QMessageBox.StandardButton.Ok:
return
backup_to = None
if self.backup.isChecked():
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
self._port, self._image = port, img
self.serial_tab.release_port() # esptool needs the port exclusively
self.output.clear()
self.output.appendPlainText("\n".join(plan.summary()))
self._show_result("", None)
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
self._bridge.output.emit, self._bridge.done.emit)
self._set_busy(True)
self._runner.start()
def _set_busy(self, busy: bool) -> None:
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full,
self.reload_btn):
w.setEnabled(not busy)
self._update()
def _on_step(self, i: int, title: str) -> None:
n = len(self._runner.plan.steps) if self._runner else 0
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
def _on_done(self, res: Result) -> None:
self._runner = None
if not res.ok:
self._set_busy(False)
self.step_label.setText("Stopped.")
hint = f"<p>{res.hint}</p>" if res.hint else ""
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
self.details_btn.setChecked(True)
return
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
self.bar.setValue(0)
self._show_result("Firmware written. Waiting for the box to start...", "info")
self.serial_tab.connect_port(self._port, capture=True)
self._verify_ms = 0
self._verify_timer.start()
def _check_verify(self) -> None:
self._verify_ms += self._verify_timer.interval()
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
fw = self.serial_tab.engine.firmware
want = self._image.app.elf_sha[:8]
done = same_build(fw.elf_sha, self._image.app.elf_sha)
if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
self._verify_timer.stop()
self._set_busy(False)
self.bar.setValue(100)
if done:
self.step_label.setText("Done.")
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
"Check the Diagnosis panel in the Serial monitor.", "ok")
else:
seen = fw.elf_sha[:8] if fw.elf_sha else "no boot seen"
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
"Open the Serial monitor and press Reset & capture.", "warning")
def _show_result(self, html: str, kind: str | None) -> None:
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
self.result.setVisible(bool(html))
if html:
border, bg = colours[kind or "info"]
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html)
def _parse_built(s: str | None) -> dt.datetime | None:
"""'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
if not s:
return None
try:
return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
except ValueError:
return None
+424 -424
View File
@@ -1,424 +1,424 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFileDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSplitter,
QTabWidget,
QTextBrowser,
QVBoxLayout,
QWidget,
)
from .. import __version__
from ..core.logfile import read_log_file
from ..core.logline import LineKind, LogLine, host_event
from ..core.session import SessionLog
from ..diag.engine import DiagnosticsEngine
from ..diag.guides import GuideRunner
from ..serialio.manager import SerialManager, State
from ..serialio.ports import PortInfo, list_serial_ports
from ..matter.payload import SetupPayload, payload_from_log
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
from .cheatsheet import cheatsheet_html
from .diag_panel import DiagnosticsPanel
from .flash_tab import FlashTab
from .matter_qr import MatterQrDialog
from .guide_panel import GuidePanel
from .sd_tab import SdTab
MAX_LINES = 20000
# "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"}}
COLORS = {
"E": "#ff6b6b",
"W": "#f5c451",
"I": "#d4d4d4",
LineKind.BOOT: "#6fc3ff",
LineKind.RESET: "#6fc3ff",
LineKind.PANIC: "#ff4dd2",
LineKind.HOST: "#7ee07e",
LineKind.KOTA: "#5fd7e8",
LineKind.RAW: "#a8a8a8",
}
class _Bridge(QObject):
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
line = Signal(object)
state = Signal(object, str)
class SerialTab(QWidget):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._bridge = _Bridge()
self._bridge.line.connect(self._on_line)
self._bridge.state.connect(self._on_state)
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
self._session = SessionLog()
self._ports: list[PortInfo] = []
self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine = DiagnosticsEngine()
self._diag_dirty = True
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
# --- controls
self.port_combo = QComboBox(minimumWidth=320)
self.all_ports = QCheckBox("Show all ports")
self.refresh_btn = QPushButton("Refresh")
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.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)
self.view.setFont(mono)
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
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(self.guide, "Guided troubleshooting")
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(self.view)
split.addWidget(self.side_tabs)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 2)
split.setSizes([700, 480])
self.status = QLabel()
layout = QVBoxLayout(self)
layout.addLayout(row1)
layout.addLayout(row2)
layout.addWidget(split, 1)
layout.addWidget(self.status)
# --- wiring
self.refresh_btn.clicked.connect(self.refresh_ports)
self.all_ports.toggled.connect(self.refresh_ports)
self.connect_btn.clicked.connect(self._toggle_connect)
self.reset_btn.clicked.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.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
# Report refresh is throttled; lines can arrive at >100/s.
self._diag_timer = QTimer(self, interval=400)
self._diag_timer.timeout.connect(self._refresh_diag)
self._diag_timer.start()
# Auto-detect a box being plugged in while idle.
self._poll = QTimer(self, interval=2000)
self._poll.timeout.connect(self._auto_refresh)
self._poll.start()
self.refresh_ports()
self._on_state(State.DISCONNECTED, "")
# ---- ports -------------------------------------------------------
def refresh_ports(self) -> None:
current = self.port_combo.currentData()
self._ports = list_serial_ports(include_all=self.all_ports.isChecked())
self.port_combo.clear()
for p in self._ports:
self.port_combo.addItem(p.label, p.device)
if not self._ports:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
idx = self.port_combo.findData(current)
self.port_combo.setCurrentIndex(max(idx, 0))
def _auto_refresh(self) -> None:
if self._manager.is_running:
return
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())]
if devices != [p.device for p in self._ports]:
self.refresh_ports()
def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData()
return next((p for p in self._ports if p.device == dev), None)
# ---- connection --------------------------------------------------
def _toggle_connect(self) -> None:
if self._manager.is_running:
# Session file is closed in _on_state(DISCONNECTED), after the
# reader thread's final queued lines have been written.
self._manager.disconnect()
return
port = self._selected_port()
if port is None:
return
self._connect(port)
def _connect(self, port: PortInfo) -> None:
self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine.reset()
self._runner.cancel()
self._diag_dirty = True
self._session.start(port.mac or port.device)
self._active_port = port
self._manager.connect(port)
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 [],
self._engine.matter_qr, self._engine.matter_manual)
# ---- service system -------------------------------------------------
def _service_base(self) -> str:
return str(self._settings.value("service/base", DEFAULT_BASE))
def _open_service_report(self, auto: bool) -> None:
mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "")
if len(mac) != 12:
if not auto:
QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. "
"Connect to the box and press Reset & capture first.")
return
url = report_url(mac, self._engine.report(), self._service_base())
QDesktopServices.openUrl(QUrl(url))
# Log that it happened, not the report itself (it is already in the session log).
self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there")
# ---- hand-off with the flash tab ------------------------------------
@property
def engine(self) -> DiagnosticsEngine:
return self._engine
def release_port(self) -> None:
"""Stop monitoring so esptool can open the port exclusively."""
if self._manager.is_running:
self._no_auto_service = True # a flash hand-off is not the end of a session
self._manager.disconnect() # joins the reader thread
QCoreApplication.processEvents() # deliver its queued final lines/state now
self._no_auto_service = False
def connect_port(self, port: PortInfo, capture: bool = True) -> None:
"""Monitor `port`; with capture, restart the box once connected to record its boot."""
self.release_port()
idx = self.port_combo.findData(port.device)
if idx < 0:
self.port_combo.addItem(port.label, port.device)
self._ports.append(port)
idx = self.port_combo.count() - 1
self.port_combo.setCurrentIndex(idx)
self._reset_on_connect = capture
self._connect(port)
def _on_state(self, state: State, detail: str) -> None:
running = state != State.DISCONNECTED
if not running and self._manager.is_running:
return # stale event from a session that was already replaced
if state == State.CONNECTED and getattr(self, "_reset_on_connect", False):
self._reset_on_connect = False
QTimer.singleShot(300, self._manager.reset_board)
if not running and self._session.path is not None:
self._session.write_report(self._engine.report().to_text())
if (self.auto_service.isChecked() and self._engine.lines
and not getattr(self, "_no_auto_service", False)):
self._open_service_report(auto=True)
self._session.stop()
self.connect_btn.setText("Disconnect" if running else "Connect")
self.reset_btn.setEnabled(state == State.CONNECTED)
self.port_combo.setEnabled(not running)
self.all_ports.setEnabled(not running)
self.refresh_btn.setEnabled(not running)
# Offline analysis would mix with the live session; disconnect first.
self.analyze_btn.setEnabled(not running)
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
self._update_status()
def _update_status(self) -> None:
sess = f" | Session log: {self._session.path}" if self._session.path else ""
c = self._counts
self.status.setText(
f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} "
f"errors: {c['E']} warnings: {c['W']}{sess}"
)
# ---- lines -------------------------------------------------------
def _on_line(self, line: LogLine) -> None:
self._session.write(line)
self._engine.feed(line)
self._runner.feed(line)
self._diag_dirty = True
if line.kind == LineKind.BOOT:
self._counts["boots"] += 1
if line.level in ("E", "W"):
self._counts[line.level] += 1
self._update_status()
if self._visible(line):
self._append(line)
def _visible(self, line: LogLine) -> bool:
allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET):
return True
if allowed == "KOTA":
return line.kind == LineKind.KOTA or line.level == "E"
return line.level in allowed
def _append(self, line: LogLine) -> None:
sb = self.view.verticalScrollBar()
at_bottom = sb.value() >= sb.maximum() - 4
fmt = QTextCharFormat()
colour = COLORS.get(line.kind) if line.kind == LineKind.KOTA and line.level == "I" else None
fmt.setForeground(QColor(colour or COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST) or line.record in ("boot", "status"):
fmt.setFontWeight(QFont.Weight.Bold)
cur = QTextCursor(self.view.document())
cur.movePosition(QTextCursor.MoveOperation.End)
if not self.view.document().isEmpty():
cur.insertBlock()
cur.insertText(line.text, fmt)
if at_bottom:
sb.setValue(sb.maximum())
# ---- diagnostics -------------------------------------------------
def _refresh_diag(self) -> None:
if self._diag_dirty:
self._diag_dirty = False
self.diag.show_report(self._engine.report())
self.guide.refresh() # countdowns and timeouts need a steady tick
# ---- guided troubleshooting hooks ------------------------------------
def _guide_action(self, action: str) -> bool:
if action == "reset" and self._manager.state == State.CONNECTED:
self._manager.reset_board()
return True
return False
def _guide_log(self, text: str) -> None:
# Technician answers go into the session log and the log view.
line = host_event(text)
self._session.write(line)
self._append(line)
self._diag_dirty = True
def _copy_report(self) -> None:
QGuiApplication.clipboard().setText(self._engine.report().to_text())
def _analyze_file(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)")
if not path:
return
lines = read_log_file(Path(path))
self.view.clear()
self._engine.reset()
self._runner.cancel()
self._counts = {"E": 0, "W": 0, "boots": 0}
for line in lines:
self._engine.feed(line)
if line.kind == LineKind.BOOT:
self._counts["boots"] += 1
if line.level in ("E", "W"):
self._counts[line.level] += 1
self._append(line)
self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)"
self._update_status()
self._diag_dirty = True
self._refresh_diag()
# ---- misc --------------------------------------------------------
def _save_view(self) -> None:
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
if path:
with open(path, "w", encoding="utf-8") as fh:
fh.write(self.view.toPlainText())
def _open_folder(self) -> None:
self._session.root.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root)))
def shutdown(self) -> None:
self._manager.disconnect()
self._session.stop()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}")
self.resize(1200, 800)
tabs = QTabWidget()
guide = QTextBrowser(openExternalLinks=True)
guide.setHtml(cheatsheet_html())
self.serial_tab = SerialTab()
self.flash_tab = FlashTab(self.serial_tab)
tabs.addTab(guide, "Quick start")
tabs.addTab(self.serial_tab, "Serial monitor")
tabs.addTab(self.flash_tab, "Flash firmware")
self.sd_tab = SdTab(self.serial_tab)
tabs.addTab(self.sd_tab, "SD card")
self.setCentralWidget(tabs)
settings_menu = self.menuBar().addMenu("Settings")
settings_menu.addAction("Service system address...", self._edit_service_base)
def _edit_service_base(self) -> None:
st = self.serial_tab
text, ok = QInputDialog.getText(
self, "Service system address",
"Address of the KOTA service system (the box MAC is added at the end):",
text=st._service_base())
if ok and text.strip().startswith(("https://", "http://")):
st._settings.setValue("service/base", text.strip())
elif ok:
QMessageBox.warning(self, "Service system address", "The address must start with https://")
def closeEvent(self, event): # noqa: N802 (Qt naming)
self.serial_tab.shutdown()
super().closeEvent(event)
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QCoreApplication, QObject, QSettings, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFileDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSplitter,
QTabWidget,
QTextBrowser,
QVBoxLayout,
QWidget,
)
from .. import __version__
from ..core.logfile import read_log_file
from ..core.logline import LineKind, LogLine, host_event
from ..core.session import SessionLog
from ..diag.engine import DiagnosticsEngine
from ..diag.guides import GuideRunner
from ..serialio.manager import SerialManager, State
from ..serialio.ports import PortInfo, list_serial_ports
from ..matter.payload import SetupPayload, payload_from_log
from ..service.report import DEFAULT_BASE, normalize_mac, report_url
from .cheatsheet import cheatsheet_html
from .diag_panel import DiagnosticsPanel
from .flash_tab import FlashTab
from .matter_qr import MatterQrDialog
from .guide_panel import GuidePanel
from .sd_tab import SdTab
MAX_LINES = 20000
# "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"}}
COLORS = {
"E": "#ff6b6b",
"W": "#f5c451",
"I": "#d4d4d4",
LineKind.BOOT: "#6fc3ff",
LineKind.RESET: "#6fc3ff",
LineKind.PANIC: "#ff4dd2",
LineKind.HOST: "#7ee07e",
LineKind.KOTA: "#5fd7e8",
LineKind.RAW: "#a8a8a8",
}
class _Bridge(QObject):
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
line = Signal(object)
state = Signal(object, str)
class SerialTab(QWidget):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._bridge = _Bridge()
self._bridge.line.connect(self._on_line)
self._bridge.state.connect(self._on_state)
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
self._session = SessionLog()
self._ports: list[PortInfo] = []
self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine = DiagnosticsEngine()
self._diag_dirty = True
self._runner = GuideRunner(self._engine, on_action=self._guide_action, on_log=self._guide_log)
# --- controls
self.port_combo = QComboBox(minimumWidth=320)
self.all_ports = QCheckBox("Show all ports")
self.refresh_btn = QPushButton("Refresh")
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.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)
self.view.setFont(mono)
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
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(self.guide, "Guided troubleshooting")
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(self.view)
split.addWidget(self.side_tabs)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 2)
split.setSizes([700, 480])
self.status = QLabel()
layout = QVBoxLayout(self)
layout.addLayout(row1)
layout.addLayout(row2)
layout.addWidget(split, 1)
layout.addWidget(self.status)
# --- wiring
self.refresh_btn.clicked.connect(self.refresh_ports)
self.all_ports.toggled.connect(self.refresh_ports)
self.connect_btn.clicked.connect(self._toggle_connect)
self.reset_btn.clicked.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.diag.qr_btn.clicked.connect(lambda: MatterQrDialog(self.matter_payload(), self).exec())
# Report refresh is throttled; lines can arrive at >100/s.
self._diag_timer = QTimer(self, interval=400)
self._diag_timer.timeout.connect(self._refresh_diag)
self._diag_timer.start()
# Auto-detect a box being plugged in while idle.
self._poll = QTimer(self, interval=2000)
self._poll.timeout.connect(self._auto_refresh)
self._poll.start()
self.refresh_ports()
self._on_state(State.DISCONNECTED, "")
# ---- ports -------------------------------------------------------
def refresh_ports(self) -> None:
current = self.port_combo.currentData()
self._ports = list_serial_ports(include_all=self.all_ports.isChecked())
self.port_combo.clear()
for p in self._ports:
self.port_combo.addItem(p.label, p.device)
if not self._ports:
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
idx = self.port_combo.findData(current)
self.port_combo.setCurrentIndex(max(idx, 0))
def _auto_refresh(self) -> None:
if self._manager.is_running:
return
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())]
if devices != [p.device for p in self._ports]:
self.refresh_ports()
def _selected_port(self) -> PortInfo | None:
dev = self.port_combo.currentData()
return next((p for p in self._ports if p.device == dev), None)
# ---- connection --------------------------------------------------
def _toggle_connect(self) -> None:
if self._manager.is_running:
# Session file is closed in _on_state(DISCONNECTED), after the
# reader thread's final queued lines have been written.
self._manager.disconnect()
return
port = self._selected_port()
if port is None:
return
self._connect(port)
def _connect(self, port: PortInfo) -> None:
self._counts = {"E": 0, "W": 0, "boots": 0}
self._engine.reset()
self._runner.cancel()
self._diag_dirty = True
self._session.start(port.mac or port.device)
self._active_port = port
self._manager.connect(port)
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 [],
self._engine.matter_qr, self._engine.matter_manual)
# ---- service system -------------------------------------------------
def _service_base(self) -> str:
return str(self._settings.value("service/base", DEFAULT_BASE))
def _open_service_report(self, auto: bool) -> None:
mac = normalize_mac(self._engine.mac or (self._active_port.mac if self._active_port else "") or "")
if len(mac) != 12:
if not auto:
QMessageBox.information(self, "Service report", "The box's MAC address is not known yet. "
"Connect to the box and press Reset & capture first.")
return
url = report_url(mac, self._engine.report(), self._service_base())
QDesktopServices.openUrl(QUrl(url))
# Log that it happened, not the report itself (it is already in the session log).
self._guide_log(f"service report for {mac} opened in the browser - check it and press Save there")
# ---- hand-off with the flash tab ------------------------------------
@property
def engine(self) -> DiagnosticsEngine:
return self._engine
def release_port(self) -> None:
"""Stop monitoring so esptool can open the port exclusively."""
if self._manager.is_running:
self._no_auto_service = True # a flash hand-off is not the end of a session
self._manager.disconnect() # joins the reader thread
QCoreApplication.processEvents() # deliver its queued final lines/state now
self._no_auto_service = False
def connect_port(self, port: PortInfo, capture: bool = True) -> None:
"""Monitor `port`; with capture, restart the box once connected to record its boot."""
self.release_port()
idx = self.port_combo.findData(port.device)
if idx < 0:
self.port_combo.addItem(port.label, port.device)
self._ports.append(port)
idx = self.port_combo.count() - 1
self.port_combo.setCurrentIndex(idx)
self._reset_on_connect = capture
self._connect(port)
def _on_state(self, state: State, detail: str) -> None:
running = state != State.DISCONNECTED
if not running and self._manager.is_running:
return # stale event from a session that was already replaced
if state == State.CONNECTED and getattr(self, "_reset_on_connect", False):
self._reset_on_connect = False
QTimer.singleShot(300, self._manager.reset_board)
if not running and self._session.path is not None:
self._session.write_report(self._engine.report().to_text())
if (self.auto_service.isChecked() and self._engine.lines
and not getattr(self, "_no_auto_service", False)):
self._open_service_report(auto=True)
self._session.stop()
self.connect_btn.setText("Disconnect" if running else "Connect")
self.reset_btn.setEnabled(state == State.CONNECTED)
self.port_combo.setEnabled(not running)
self.all_ports.setEnabled(not running)
self.refresh_btn.setEnabled(not running)
# Offline analysis would mix with the live session; disconnect first.
self.analyze_btn.setEnabled(not running)
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
self._update_status()
def _update_status(self) -> None:
sess = f" | Session log: {self._session.path}" if self._session.path else ""
c = self._counts
self.status.setText(
f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} "
f"errors: {c['E']} warnings: {c['W']}{sess}"
)
# ---- lines -------------------------------------------------------
def _on_line(self, line: LogLine) -> None:
self._session.write(line)
self._engine.feed(line)
self._runner.feed(line)
self._diag_dirty = True
if line.kind == LineKind.BOOT:
self._counts["boots"] += 1
if line.level in ("E", "W"):
self._counts[line.level] += 1
self._update_status()
if self._visible(line):
self._append(line)
def _visible(self, line: LogLine) -> bool:
allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
if allowed is None or line.kind in (LineKind.HOST, LineKind.PANIC, LineKind.BOOT, LineKind.RESET):
return True
if allowed == "KOTA":
return line.kind == LineKind.KOTA or line.level == "E"
return line.level in allowed
def _append(self, line: LogLine) -> None:
sb = self.view.verticalScrollBar()
at_bottom = sb.value() >= sb.maximum() - 4
fmt = QTextCharFormat()
colour = COLORS.get(line.kind) if line.kind == LineKind.KOTA and line.level == "I" else None
fmt.setForeground(QColor(colour or COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST) or line.record in ("boot", "status"):
fmt.setFontWeight(QFont.Weight.Bold)
cur = QTextCursor(self.view.document())
cur.movePosition(QTextCursor.MoveOperation.End)
if not self.view.document().isEmpty():
cur.insertBlock()
cur.insertText(line.text, fmt)
if at_bottom:
sb.setValue(sb.maximum())
# ---- diagnostics -------------------------------------------------
def _refresh_diag(self) -> None:
if self._diag_dirty:
self._diag_dirty = False
self.diag.show_report(self._engine.report())
self.guide.refresh() # countdowns and timeouts need a steady tick
# ---- guided troubleshooting hooks ------------------------------------
def _guide_action(self, action: str) -> bool:
if action == "reset" and self._manager.state == State.CONNECTED:
self._manager.reset_board()
return True
return False
def _guide_log(self, text: str) -> None:
# Technician answers go into the session log and the log view.
line = host_event(text)
self._session.write(line)
self._append(line)
self._diag_dirty = True
def _copy_report(self) -> None:
QGuiApplication.clipboard().setText(self._engine.report().to_text())
def _analyze_file(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "Analyze log file", str(self._session.root), "Logs (*.log *.txt);;All files (*)")
if not path:
return
lines = read_log_file(Path(path))
self.view.clear()
self._engine.reset()
self._runner.cancel()
self._counts = {"E": 0, "W": 0, "boots": 0}
for line in lines:
self._engine.feed(line)
if line.kind == LineKind.BOOT:
self._counts["boots"] += 1
if line.level in ("E", "W"):
self._counts[line.level] += 1
self._append(line)
self._state_text = f"Analyzed file: {Path(path).name} ({len(lines)} lines)"
self._update_status()
self._diag_dirty = True
self._refresh_diag()
# ---- misc --------------------------------------------------------
def _save_view(self) -> None:
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
if path:
with open(path, "w", encoding="utf-8") as fh:
fh.write(self.view.toPlainText())
def _open_folder(self) -> None:
self._session.root.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root)))
def shutdown(self) -> None:
self._manager.disconnect()
self._session.stop()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}")
self.resize(1200, 800)
tabs = QTabWidget()
guide = QTextBrowser(openExternalLinks=True)
guide.setHtml(cheatsheet_html())
self.serial_tab = SerialTab()
self.flash_tab = FlashTab(self.serial_tab)
tabs.addTab(guide, "Quick start")
tabs.addTab(self.serial_tab, "Serial monitor")
tabs.addTab(self.flash_tab, "Flash firmware")
self.sd_tab = SdTab(self.serial_tab)
tabs.addTab(self.sd_tab, "SD card")
self.setCentralWidget(tabs)
settings_menu = self.menuBar().addMenu("Settings")
settings_menu.addAction("Service system address...", self._edit_service_base)
def _edit_service_base(self) -> None:
st = self.serial_tab
text, ok = QInputDialog.getText(
self, "Service system address",
"Address of the KOTA service system (the box MAC is added at the end):",
text=st._service_base())
if ok and text.strip().startswith(("https://", "http://")):
st._settings.setValue("service/base", text.strip())
elif ok:
QMessageBox.warning(self, "Service system address", "The address must start with https://")
def closeEvent(self, event): # noqa: N802 (Qt naming)
self.serial_tab.shutdown()
super().closeEvent(event)
+76 -76
View File
@@ -1,76 +1,76 @@
"""Matter pairing QR code on screen, so the technician can scan it from the laptop."""
from __future__ import annotations
import segno
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
from ..matter.payload import SetupPayload
def qr_pixmap(text: str, size: int = 260) -> QPixmap:
"""Black-on-white QR with the quiet zone phones need (4 modules)."""
qr = segno.make(text, error="m")
matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)]
n = len(matrix)
module = max(1, size // n)
img = QImage(n * module, n * module, QImage.Format.Format_RGB32)
img.fill(QColor("white"))
p = QPainter(img)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor("black"))
for y, row in enumerate(matrix):
for x, dark in enumerate(row):
if dark:
p.drawRect(x * module, y * module, module, module)
p.end()
return QPixmap.fromImage(img)
class MatterQrWidget(QWidget):
def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None):
super().__init__(parent)
self.image = QLabel()
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code = QLabel()
self.code.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
f = self.code.font()
f.setPointSize(f.pointSize() + 4)
f.setBold(True)
self.code.setFont(f)
self.note = QLabel(wordWrap=True)
self.note.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.note.setStyleSheet("color:#555")
lay = QVBoxLayout(self)
lay.addWidget(self.image)
lay.addWidget(self.code)
lay.addWidget(self.note)
self._size = size
self.set_payload(payload)
def set_payload(self, payload: SetupPayload) -> None:
self.payload = payload
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
if payload.from_box:
src = "QR and manual code exactly as reported by the box (KOTA: matter line)"
else:
src = f"Discriminator {payload.discriminator} " + (
"(from this box's log)" if payload.from_log else
"(standard code of the v1.0 firmware - connect the box to confirm)")
self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. "
f"{src}. Same as the sticker on the box.")
class MatterQrDialog(QDialog):
def __init__(self, payload: SetupPayload, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Matter pairing code")
lay = QVBoxLayout(self)
lay.addWidget(MatterQrWidget(payload, 320))
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
"""Matter pairing QR code on screen, so the technician can scan it from the laptop."""
from __future__ import annotations
import segno
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
from ..matter.payload import SetupPayload
def qr_pixmap(text: str, size: int = 260) -> QPixmap:
"""Black-on-white QR with the quiet zone phones need (4 modules)."""
qr = segno.make(text, error="m")
matrix = [list(row) for row in qr.matrix_iter(scale=1, border=4)]
n = len(matrix)
module = max(1, size // n)
img = QImage(n * module, n * module, QImage.Format.Format_RGB32)
img.fill(QColor("white"))
p = QPainter(img)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor("black"))
for y, row in enumerate(matrix):
for x, dark in enumerate(row):
if dark:
p.drawRect(x * module, y * module, module, module)
p.end()
return QPixmap.fromImage(img)
class MatterQrWidget(QWidget):
def __init__(self, payload: SetupPayload, size: int = 260, parent: QWidget | None = None):
super().__init__(parent)
self.image = QLabel()
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code = QLabel()
self.code.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.code.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
f = self.code.font()
f.setPointSize(f.pointSize() + 4)
f.setBold(True)
self.code.setFont(f)
self.note = QLabel(wordWrap=True)
self.note.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.note.setStyleSheet("color:#555")
lay = QVBoxLayout(self)
lay.addWidget(self.image)
lay.addWidget(self.code)
lay.addWidget(self.note)
self._size = size
self.set_payload(payload)
def set_payload(self, payload: SetupPayload) -> None:
self.payload = payload
self.image.setPixmap(qr_pixmap(payload.qr_text(), self._size))
self.code.setText(f"Manual code: {payload.manual_code_pretty()}")
if payload.from_box:
src = "QR and manual code exactly as reported by the box (KOTA: matter line)"
else:
src = f"Discriminator {payload.discriminator} " + (
"(from this box's log)" if payload.from_log else
"(standard code of the v1.0 firmware - connect the box to confirm)")
self.note.setText(f"Scan with the Matter app (Apple Home, Google Home, ...), or type the manual code. "
f"{src}. Same as the sticker on the box.")
class MatterQrDialog(QDialog):
def __init__(self, payload: SetupPayload, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Matter pairing code")
lay = QVBoxLayout(self)
lay.addWidget(MatterQrWidget(payload, 320))
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)