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:
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.gui.trace_png_export import export_trace_png
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
@@ -519,13 +521,23 @@ class AppWindowPreprocessMixin:
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
# The capture blocks the event loop, so clicks made during it are delivered
|
||||
# only after it finishes. `_begin_preprocess_capture` disables the action
|
||||
# buttons for that whole window (re-enabled via a posted event), so a queued
|
||||
# click lands on a disabled button instead of silently starting — and
|
||||
# advancing the combo cursor of — another capture.
|
||||
if not self._begin_preprocess_capture():
|
||||
return
|
||||
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
finally:
|
||||
self._end_preprocess_capture()
|
||||
|
||||
def _capture_all_remaining(self) -> None:
|
||||
"""Capture all remaining combos for the active preprocess session."""
|
||||
@@ -539,6 +551,8 @@ class AppWindowPreprocessMixin:
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return
|
||||
if not self._begin_preprocess_capture():
|
||||
return
|
||||
|
||||
display_name = preprocess_asset_display_name(session.kind)
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
@@ -547,13 +561,40 @@ class AppWindowPreprocessMixin:
|
||||
f"{display_name} batch capture started: remaining="
|
||||
f"{session.state().total_count - session.state().captured_count}"
|
||||
)
|
||||
while not session.is_complete():
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
try:
|
||||
while not session.is_complete():
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
finally:
|
||||
self._end_preprocess_capture()
|
||||
|
||||
def _begin_preprocess_capture(self) -> bool:
|
||||
"""Mark a blocking combo capture as running; refuse when one already is.
|
||||
|
||||
Returns False for a duplicate request (e.g. a click delivered while an
|
||||
error dialog inside a capture pumps the event loop).
|
||||
"""
|
||||
if self._preprocess_capture_busy:
|
||||
self._log("Preprocess combo capture already in progress; ignoring duplicate request.")
|
||||
return False
|
||||
self._preprocess_capture_busy = True
|
||||
# Disable the sequence action buttons for the whole blocked window.
|
||||
self._update_capture_dialog_state()
|
||||
return True
|
||||
|
||||
def _end_preprocess_capture(self) -> None:
|
||||
"""Re-enable capture actions after the pending input backlog is discarded.
|
||||
|
||||
The zero-delay timer fires only after Qt has dispatched the window-system
|
||||
events queued while the capture blocked the loop; those clicks hit the
|
||||
still-disabled buttons and are dropped, then the buttons come back.
|
||||
"""
|
||||
self._preprocess_capture_busy = False
|
||||
QTimer.singleShot(0, self._update_capture_dialog_state)
|
||||
|
||||
def _on_capture_combo_failed(
|
||||
self,
|
||||
@@ -817,6 +858,10 @@ class AppWindowPreprocessMixin:
|
||||
and state.current_combo is not None
|
||||
),
|
||||
variant_count=state.variant_count,
|
||||
# While a blocking capture is executing, every action stays disabled no
|
||||
# matter what the session state allows: clicks queued during the freeze
|
||||
# must land on disabled buttons (see `_end_preprocess_capture`).
|
||||
actions_enabled=not self._preprocess_capture_busy,
|
||||
)
|
||||
|
||||
def _cleanup_capture_session(self) -> None:
|
||||
|
||||
@@ -354,8 +354,14 @@ class PreprocessDialog(QDialog):
|
||||
can_finalize: bool,
|
||||
can_capture_all: bool,
|
||||
variant_count: int = 1,
|
||||
actions_enabled: bool = True,
|
||||
) -> None:
|
||||
"""Update sequence progress/status widgets."""
|
||||
"""Update sequence progress/status widgets.
|
||||
|
||||
With ``actions_enabled=False`` the progress labels still update but every
|
||||
sequence action button is kept disabled — used while a blocking capture
|
||||
runs, so input queued during the freeze cannot trigger another action.
|
||||
"""
|
||||
if kind is None:
|
||||
self._active_kind_label.setText("<none>")
|
||||
self._progress_label.setText("0 / 0")
|
||||
@@ -368,13 +374,14 @@ class PreprocessDialog(QDialog):
|
||||
self._capture_all_button.setText("Capture All Remaining")
|
||||
return
|
||||
|
||||
actions_enabled = bool(actions_enabled)
|
||||
active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind
|
||||
self._active_kind_label.setText(active_label)
|
||||
self._progress_label.setText(f"{captured_count} / {total_count}")
|
||||
self._undo_last_button.setEnabled(bool(can_undo))
|
||||
self._save_sequence_button.setEnabled(bool(can_finalize))
|
||||
self._capture_all_button.setEnabled(bool(can_capture_all))
|
||||
self._abort_button.setEnabled(True)
|
||||
self._undo_last_button.setEnabled(bool(can_undo) and actions_enabled)
|
||||
self._save_sequence_button.setEnabled(bool(can_finalize) and actions_enabled)
|
||||
self._capture_all_button.setEnabled(bool(can_capture_all) and actions_enabled)
|
||||
self._abort_button.setEnabled(actions_enabled)
|
||||
self._capture_all_button.setText("Capture All Remaining")
|
||||
if next_input is None or next_output is None:
|
||||
self._combo_label.setText("<complete>")
|
||||
@@ -384,7 +391,7 @@ class PreprocessDialog(QDialog):
|
||||
if int(variant_count) > 1:
|
||||
combo_text += f" | radar configs={int(variant_count)}"
|
||||
self._combo_label.setText(combo_text)
|
||||
self._capture_next_button.setEnabled(True)
|
||||
self._capture_next_button.setEnabled(actions_enabled)
|
||||
|
||||
def set_available_sets(self, available_sets: dict[str, list[str]]) -> None:
|
||||
"""Replace combo-box choices for all preprocess assets."""
|
||||
|
||||
Reference in New Issue
Block a user