235 lines
8.9 KiB
Python
235 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
|
from PySide6.QtGui import QColor, QDesktopServices, QFont, QTextCharFormat, QTextCursor
|
|
from PySide6.QtWidgets import (
|
|
QCheckBox,
|
|
QComboBox,
|
|
QFileDialog,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QMainWindow,
|
|
QPlainTextEdit,
|
|
QPushButton,
|
|
QTabWidget,
|
|
QTextBrowser,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from .. import __version__
|
|
from ..core.logline import LineKind, LogLine
|
|
from ..core.session import SessionLog
|
|
from ..serialio.manager import SerialManager, State
|
|
from ..serialio.ports import PortInfo, list_serial_ports
|
|
from .cheatsheet import cheatsheet_html
|
|
|
|
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}
|
|
|
|
# --- 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")
|
|
|
|
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):
|
|
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.status = QLabel()
|
|
layout = QVBoxLayout(self)
|
|
layout.addLayout(row1)
|
|
layout.addLayout(row2)
|
|
layout.addWidget(self.view, 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)
|
|
|
|
# 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._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:
|
|
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)
|
|
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)
|
|
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())
|
|
|
|
# ---- 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)
|