some fixes

This commit is contained in:
Ayzen
2026-08-26 15:02:21 +03:00
parent 74723bb635
commit 6ada811c2f
10 changed files with 607 additions and 115 deletions
+106 -42
View File
@@ -15,9 +15,10 @@ import logging
import os
from pathlib import Path
import sys
import threading
import traceback
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
from PyQt6.QtCore import QTimer
from PyQt6.QtGui import QTextCursor
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
@@ -58,33 +59,60 @@ def _panel_extra(details: str | None, once_key: str | None) -> dict[str, object]
return {"panel_details": details, "panel_once_key": once_key}
class _PanelLogBridge(QObject):
"""Marshals log records from any thread onto the GUI thread for panel rendering.
class _PanelLogBuffer:
"""Thread-safe bounded buffer between logging handlers and the GUI flush timer.
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster),
but the log widget may only be touched on the GUI thread; emitting this queued
signal hands the record across safely (the GPIO-button pattern).
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster)
at a very high rate — e.g. the USB RX threads while the free-running sweep
streams. Posting one queued Qt event per record used to flood the GUI event
queue and keep the interface frozen long after a blocking operation finished
while the backlog rendered. Instead, records land in this bounded buffer and
a periodic GUI-side timer drains them in one batch; overflow drops the oldest
records and reports how many were lost.
"""
record = pyqtSignal(str, str, object, object) # display level, message, details, once_key
_CAPACITY = 2000
def __init__(self) -> None:
self._lock = threading.Lock()
self._entries: deque[tuple[str, str, str | None, str | None]] = deque(maxlen=self._CAPACITY)
self._dropped_count = 0
def append(self, level: str, text: str, details: str | None, once_key: str | None) -> None:
"""Store one record, evicting the oldest when full (any thread)."""
with self._lock:
if len(self._entries) == self._CAPACITY:
self._dropped_count += 1
self._entries.append((level, text, details, once_key))
def drain(self) -> tuple[list[tuple[str, str, str | None, str | None]], int]:
"""Return and clear all buffered records plus the overflow-drop count."""
with self._lock:
entries = list(self._entries)
self._entries.clear()
dropped_count = self._dropped_count
self._dropped_count = 0
return entries, dropped_count
class _QtLogPanelHandler(logging.Handler):
"""Logging handler that forwards application log records to the GUI log panel."""
def __init__(self, bridge: _PanelLogBridge) -> None:
def __init__(self, buffer: _PanelLogBuffer) -> None:
super().__init__()
self._bridge = bridge
self._buffer = buffer
def emit(self, record: logging.LogRecord) -> None:
"""Forward one record to the panel bridge, mapping WARNING to the short 'WARN'."""
"""Buffer one record for the panel, mapping WARNING to the short 'WARN'."""
try:
display_level = "WARN" if record.levelname == "WARNING" else record.levelname
self._bridge.record.emit(
details = getattr(record, "panel_details", None)
once_key = getattr(record, "panel_once_key", None)
self._buffer.append(
display_level,
record.getMessage(),
getattr(record, "panel_details", None),
getattr(record, "panel_once_key", None),
details if isinstance(details, str) else None,
once_key if isinstance(once_key, str) else None,
)
except Exception: # noqa: BLE001 - logging must never raise into the caller
self.handleError(record)
@@ -145,23 +173,53 @@ class AppWindow(
log_dir = self._project_root / "python_app/runtime/logs"
configure_logging(level=DEFAULT_LOG_LEVEL, log_dir=log_dir, console=True)
self._gui_logger = get_logger("gui")
self._log_panel_bridge = _PanelLogBridge()
self._log_panel_bridge.record.connect(self._on_log_record)
self._log_panel_buffer = _PanelLogBuffer()
def _attach_log_panel(self) -> None:
"""Route application log records into the on-screen panel (widget now exists)."""
add_handler(_QtLogPanelHandler(self._log_panel_bridge))
add_handler(_QtLogPanelHandler(self._log_panel_buffer))
# One bounded flush per tick instead of one queued event per record: the
# panel can never flood the GUI event queue, no matter how chatty a
# DEBUG-level driver gets.
self._log_flush_timer = QTimer(self)
self._log_flush_timer.setInterval(100)
self._log_flush_timer.timeout.connect(self._flush_log_panel_buffer)
self._log_flush_timer.start()
def _on_log_record(self, level: str, text: str, details: object, once_key: object) -> None:
"""Render one forwarded log record in the panel (always on the GUI thread)."""
if not hasattr(self, "_log_box"):
def _flush_log_panel_buffer(self) -> None:
"""Render every buffered log record into the panel as one batched insert."""
entries, dropped_count = self._log_panel_buffer.drain()
if (not entries and not dropped_count) or not hasattr(self, "_log_box"):
return
self._append_log_entry(
level,
text,
details=details if isinstance(details, str) else None,
once_key=once_key if isinstance(once_key, str) else None,
)
entry_htmls: list[str] = []
if dropped_count:
entry_htmls.append(
self._render_log_entry_html(
"WARN",
f"Log panel overflow: {dropped_count} record(s) dropped "
"(they are still in the log file).",
)
)
error_seen = False
for level, text, details, once_key in entries:
if once_key is not None:
if once_key in self._logged_once_keys:
continue
self._logged_once_keys.add(once_key)
entry_htmls.append(self._render_log_entry_html(level, text, details))
error_seen = error_seen or level.upper() == "ERROR"
if not entry_htmls:
return
cursor = self._log_box.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self._log_box.setTextCursor(cursor)
self._log_box.insertHtml("".join(entry_htmls))
self._log_box.insertPlainText("\n")
self._log_box.ensureCursorVisible()
if error_seen and hasattr(self, "_status_label"):
self._status_label.setText("Status: error")
def _init_runtime_services(self) -> None:
"""Initialize long-lived service objects used by mixins."""
@@ -266,6 +324,10 @@ class AppWindow(
def _init_capture_state(self) -> None:
"""Initialize one-shot capture and sequence-control flags."""
self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None
# Guards the blocking per-combo capture against duplicate requests, and keeps
# the dialog's action buttons disabled until the post-capture input backlog
# is dropped (see AppWindowPreprocessMixin._begin/_end_preprocess_capture).
self._preprocess_capture_busy = False
self._resume_pipeline_after_capture = False
self._single_capture_active = False
self._single_capture_start_ns: int | None = None
@@ -551,20 +613,8 @@ class AppWindow(
"""Return full chained traceback for error dialogs and log details."""
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
def _append_log_entry(
self,
level: str,
text: str,
*,
details: str | None = None,
once_key: str | None = None,
) -> None:
"""Append formatted log entry with timestamp and optional details."""
if once_key is not None:
if once_key in self._logged_once_keys:
return
self._logged_once_keys.add(once_key)
def _render_log_entry_html(self, level: str, text: str, details: str | None = None) -> str:
"""Render one log entry as the panel's HTML block."""
level_upper = level.upper()
palette = {
"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"),
@@ -586,16 +636,30 @@ class AppWindow(
"<pre style='margin:3px 0 0 16px; color:"
f"{detail_color};'>{html.escape(details)}</pre>"
)
return "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
def _append_log_entry(
self,
level: str,
text: str,
*,
details: str | None = None,
once_key: str | None = None,
) -> None:
"""Append formatted log entry with timestamp and optional details."""
if once_key is not None:
if once_key in self._logged_once_keys:
return
self._logged_once_keys.add(once_key)
entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
cursor = self._log_box.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self._log_box.setTextCursor(cursor)
self._log_box.insertHtml(entry_html)
self._log_box.insertHtml(self._render_log_entry_html(level, text, details))
self._log_box.insertPlainText("\n")
self._log_box.ensureCursorVisible()
if level_upper == "ERROR" and hasattr(self, "_status_label"):
if level.upper() == "ERROR" and hasattr(self, "_status_label"):
self._status_label.setText("Status: error")
def _on_log_level_selected(self, level_text: str) -> None: