from __future__ import annotations from pathlib import Path from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import ( QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QMainWindow, QPlainTextEdit, QPushButton, QSplitter, QTabWidget, QTextBrowser, QVBoxLayout, QWidget, ) from .. import __version__ from ..core.logfile import read_log_file from ..core.logline import LineKind, LogLine from ..core.session import SessionLog from ..diag.engine import DiagnosticsEngine from ..serialio.manager import SerialManager, State from ..serialio.ports import PortInfo, list_serial_ports from .cheatsheet import cheatsheet_html from .diag_panel import DiagnosticsPanel MAX_LINES = 20000 LEVEL_FILTERS = {"All lines": None, "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.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 # --- 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)") 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): 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() split = QSplitter(Qt.Orientation.Horizontal) split.addWidget(self.view) split.addWidget(self.diag) 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) # 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._counts = {"E": 0, "W": 0, "boots": 0} self._engine.reset() self._diag_dirty = True self._session.start(port.mac or port.device) self._manager.connect(port) def _on_state(self, state: State, detail: str) -> None: running = state != State.DISCONNECTED if not running and self._session.path is not None: self._session.write_report(self._engine.report().to_text()) 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._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() allowed = LEVEL_FILTERS[self.filter_combo.currentText()] if allowed is not None and line.kind != LineKind.HOST and not ( line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET) ): return self._append(line) def _append(self, line: LogLine) -> None: sb = self.view.verticalScrollBar() at_bottom = sb.value() >= sb.maximum() - 4 fmt = QTextCharFormat() fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4"))) if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST): 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()) 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._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() tabs.addTab(guide, "Quick start") tabs.addTab(self.serial_tab, "Serial monitor") self.setCentralWidget(tabs) def closeEvent(self, event): # noqa: N802 (Qt naming) self.serial_tab.shutdown() super().closeEvent(event)