some fixes
This commit is contained in:
+106
-42
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user