diff --git a/.gitignore b/.gitignore index 4d01637..61e458b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ __pycache__/ venv/ env/ +# Log files +logs/ + # Local/runtime data captures/photos/* captures/videos/* diff --git a/app/camera/camera_enumerator.py b/app/camera/camera_enumerator.py index 3285bf6..039daaa 100644 --- a/app/camera/camera_enumerator.py +++ b/app/camera/camera_enumerator.py @@ -2,9 +2,49 @@ from __future__ import annotations +import logging from dataclasses import dataclass, field -from PySide6.QtMultimedia import QCameraDevice, QMediaDevices +from PySide6.QtMultimedia import QCameraDevice, QMediaDevices, QVideoFrameFormat + +logger = logging.getLogger(__name__) + +# Map Qt pixel format enum → human-readable string +_PIXEL_FORMAT_NAMES: dict[QVideoFrameFormat.PixelFormat, str] = { + QVideoFrameFormat.PixelFormat.Format_BGRA8888: "BGRA", + QVideoFrameFormat.PixelFormat.Format_BGRX8888: "BGRX", + QVideoFrameFormat.PixelFormat.Format_ABGR8888: "ABGR", + QVideoFrameFormat.PixelFormat.Format_ARGB8888: "ARGB", + QVideoFrameFormat.PixelFormat.Format_RGBA8888: "RGBA", + QVideoFrameFormat.PixelFormat.Format_RGBX8888: "RGBX", + QVideoFrameFormat.PixelFormat.Format_YUV420P: "YUV420P", + QVideoFrameFormat.PixelFormat.Format_YUV422P: "YUV422P", + QVideoFrameFormat.PixelFormat.Format_NV12: "NV12", + QVideoFrameFormat.PixelFormat.Format_NV21: "NV21", + QVideoFrameFormat.PixelFormat.Format_UYVY: "UYVY", + QVideoFrameFormat.PixelFormat.Format_YUYV: "YUY2", + QVideoFrameFormat.PixelFormat.Format_Jpeg: "MJPG", + QVideoFrameFormat.PixelFormat.Format_Y8: "GRAY8", + QVideoFrameFormat.PixelFormat.Format_Y16: "GRAY16", +} + + +def pixel_format_name(fmt: QVideoFrameFormat.PixelFormat) -> str: + """Return a short human-readable name for a Qt pixel format.""" + return _PIXEL_FORMAT_NAMES.get(fmt, f"fmt_{fmt.value if hasattr(fmt, 'value') else fmt}") + + +@dataclass +class CameraFormat: + """One supported video format entry for a camera device.""" + + width: int + height: int + max_fps: float + pixel_format: str # e.g. "MJPG", "YUY2", "NV12", "BGRA" + + def __str__(self) -> str: + return f"{self.pixel_format:6s} {self.width}x{self.height} @ {self.max_fps:.1f} fps" @dataclass @@ -14,8 +54,7 @@ class CameraInfo: device: QCameraDevice name: str id: str - formats: list[tuple[int, int, float]] = field(default_factory=list) - # formats: list of (width, height, max_fps) + formats: list[CameraFormat] = field(default_factory=list) def __str__(self) -> str: return f"{self.name} [{self.id}]" @@ -31,30 +70,42 @@ class CameraEnumerator: cameras: list[CameraInfo] = [] for device in devices: - formats: list[tuple[int, int, float]] = [] - for fmt in device.videoFormats(): - res = fmt.resolution() - fps = fmt.maxFrameRate() - formats.append((res.width(), res.height(), fps)) + formats: list[CameraFormat] = [] + for qfmt in device.videoFormats(): + res = qfmt.resolution() + formats.append(CameraFormat( + width=res.width(), + height=res.height(), + max_fps=qfmt.maxFrameRate(), + pixel_format=pixel_format_name(qfmt.pixelFormat()), + )) - # deduplicate and sort: largest resolution first, then fps descending - seen: set[tuple[int, int, float]] = set() - unique_formats: list[tuple[int, int, float]] = [] - for f in sorted(formats, key=lambda x: (x[0] * x[1], x[2]), reverse=True): - if f not in seen: - seen.add(f) - unique_formats.append(f) + # deduplicate; sort: largest area first, then fps descending + seen: set[tuple[int, int, float, str]] = set() + unique: list[CameraFormat] = [] + for f in sorted( + formats, + key=lambda x: (x.width * x.height, x.max_fps), + reverse=True, + ): + key = (f.width, f.height, f.max_fps, f.pixel_format) + if key not in seen: + seen.add(key) + unique.append(f) - cameras.append( - CameraInfo( - device=device, - name=device.description(), - id=device.id().toStdString() - if hasattr(device.id(), "toStdString") - else device.id().data().decode("utf-8", errors="replace"), - formats=unique_formats, - ) - ) + cam_id = CameraEnumerator._device_id(device) + cameras.append(CameraInfo( + device=device, + name=device.description(), + id=cam_id, + formats=unique, + )) + + logger.info("Cameras found: %d", len(cameras)) + for idx, cam in enumerate(cameras): + logger.info(" [%d] %s (id: %s)", idx, cam.name, cam.id) + for fmt in cam.formats: + logger.info(" %s", fmt) return cameras @@ -65,16 +116,17 @@ class CameraEnumerator: if device.isNull(): return None - cameras = CameraEnumerator.list_cameras() - # find by id match - default_id = ( - device.id().toStdString() - if hasattr(device.id(), "toStdString") - else device.id().data().decode("utf-8", errors="replace") - ) - for cam in cameras: + default_id = CameraEnumerator._device_id(device) + for cam in CameraEnumerator.list_cameras(): if cam.id == default_id: return cam - # fallback: wrap directly + cameras = CameraEnumerator.list_cameras() return cameras[0] if cameras else None + + @staticmethod + def _device_id(device: QCameraDevice) -> str: + raw = device.id() + if hasattr(raw, "toStdString"): + return raw.toStdString() + return raw.data().decode("utf-8", errors="replace") diff --git a/app/camera/camera_service.py b/app/camera/camera_service.py index bf90445..af3720d 100644 --- a/app/camera/camera_service.py +++ b/app/camera/camera_service.py @@ -12,7 +12,7 @@ from PySide6.QtMultimedia import ( QVideoSink, ) -from app.camera.camera_enumerator import CameraInfo +from app.camera.camera_enumerator import CameraInfo, pixel_format_name from app.config import DEFAULT_FPS, DEFAULT_HEIGHT, DEFAULT_WIDTH logger = logging.getLogger(__name__) @@ -28,14 +28,13 @@ class CameraService(QObject): camera_stopped() — camera stopped (clean shutdown) camera_error(str) — camera error description format_changed(float) — actual FPS after format was applied - (emitted after camera restarts with new format) """ frame_ready = Signal(QVideoFrame) camera_started = Signal() camera_stopped = Signal() camera_error = Signal(str) - format_changed = Signal(float) # actual FPS delivered by camera after format change + format_changed = Signal(float) def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) @@ -44,8 +43,9 @@ class CameraService(QObject): self._session = QMediaCaptureSession(self) self._sink = QVideoSink(self) self._current_info: CameraInfo | None = None + self._session_logged: bool = False - # Desired format — applied on next (re)start + # Desired format — applied on every (re)start self._desired_width: int = DEFAULT_WIDTH self._desired_height: int = DEFAULT_HEIGHT self._desired_fps: float = float(DEFAULT_FPS) @@ -85,28 +85,18 @@ class CameraService(QObject): logger.warning("Reconnect requested but no camera was previously started") def set_resolution(self, width: int, height: int) -> None: - """ - Request a new resolution. - - The camera is stopped and restarted so the backend reliably applies - the new format (QCamera.setCameraFormat on an active camera is often - silently ignored by Media Foundation on Windows). - """ + """Request a new resolution — restarts camera to apply reliably.""" self._desired_width = width self._desired_height = height if self._current_info is not None: - logger.info("Resolution change requested: %dx%d — restarting camera", width, height) + logger.info("Resolution change: %dx%d — restarting camera", width, height) self.start(self._current_info) def set_fps(self, fps: float) -> None: - """ - Request a new frame rate. - - Same stop+start strategy as set_resolution(). - """ + """Request a new frame rate — restarts camera to apply reliably.""" self._desired_fps = fps if self._current_info is not None: - logger.info("FPS change requested: %.1f — restarting camera", fps) + logger.info("FPS change: %.1f — restarting camera", fps) self.start(self._current_info) @property @@ -117,10 +107,6 @@ class CameraService(QObject): def current_camera(self) -> CameraInfo | None: return self._current_info - # ------------------------------------------------------------------ - # Internal video output accessors (kept for future use) - # ------------------------------------------------------------------ - def video_sink(self) -> QVideoSink: return self._sink @@ -132,7 +118,6 @@ class CameraService(QObject): # ------------------------------------------------------------------ def _stop_camera(self) -> None: - """Stop and destroy the QCamera object without clearing _current_info.""" if self._camera is not None: self._camera.stop() self._camera.errorOccurred.disconnect() @@ -141,14 +126,7 @@ class CameraService(QObject): logger.debug("Camera stopped (internal)") def _apply_format(self) -> None: - """ - Select the best matching QCameraFormat and apply it before start(). - - The format is chosen by score: - +1000 exact resolution match - +100 exact FPS match (within 1 fps) - -|Δpixels| area proximity (tie-breaker) - """ + """Select and apply the best matching QCameraFormat before start().""" if self._camera is None or self._current_info is None: return @@ -172,9 +150,10 @@ class CameraService(QObject): if best is not None: self._camera.setCameraFormat(best) res = best.resolution() + pf = pixel_format_name(best.pixelFormat()) logger.info( - "Camera format requested: %dx%d @ %.1f fps", - res.width(), res.height(), best.maxFrameRate(), + "Camera format requested: %s %dx%d @ %.1f fps", + pf, res.width(), res.height(), best.maxFrameRate(), ) def _log_actual_format(self) -> None: @@ -184,18 +163,36 @@ class CameraService(QObject): fmt = self._camera.cameraFormat() res = fmt.resolution() actual_fps = fmt.maxFrameRate() + pf = pixel_format_name(fmt.pixelFormat()) + logger.info( - "Camera format ACTUAL: %dx%d @ %.1f fps", - res.width(), res.height(), actual_fps, + "Camera format ACTUAL: %s %dx%d @ %.1f fps", + pf, res.width(), res.height(), actual_fps, ) - if actual_fps != self._desired_fps: + if abs(actual_fps - self._desired_fps) > 0.5: logger.warning( - "Requested %.1f fps but camera is delivering %.1f fps " - "(camera may not support this combination)", + "Requested %.1f fps but camera delivering %.1f fps " + "(camera may not support this resolution+fps combination)", self._desired_fps, actual_fps, ) self.format_changed.emit(actual_fps) + def _log_qt_backend(self) -> None: + """Log the Qt multimedia backend in use (once per session).""" + try: + # QMediaDevices doesn't expose backend name directly in Qt6, + # but we can infer it from platform + import platform # noqa: PLC0415 + system = platform.system() + backend = { + "Darwin": "AVFoundation", + "Windows": "Media Foundation (DirectShow fallback)", + "Linux": "GStreamer / V4L2", + }.get(system, system) + logger.info("Qt multimedia backend: %s", backend) + except Exception: + pass + def _on_frame(self, frame: QVideoFrame) -> None: if frame.isValid(): self.frame_ready.emit(frame) @@ -207,8 +204,11 @@ class CameraService(QObject): def _on_active_changed(self, active: bool) -> None: if active: name = self._current_info.name if self._current_info else "?" + if not self._session_logged: + self._log_qt_backend() + self._session_logged = True logger.info("Camera active: %s", name) - self._log_actual_format() # report what the camera actually accepted + self._log_actual_format() self.camera_started.emit() else: logger.info("Camera inactive") diff --git a/app/config.py b/app/config.py index 171896a..12869a2 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,7 @@ """Application-wide constants and default settings.""" +from pathlib import Path + APP_NAME = "Duck Preview" APP_VERSION = "0.1.0" @@ -20,3 +22,8 @@ OVERLAY_MARGIN = 10 # Frame dispatcher DISPATCHER_MAX_QUEUE_SIZE = 2 # max pending frames per slow subscriber before drop + +# Logging +LOG_DIR = Path("logs") # relative to CWD (project root) +MAX_LOG_FILES = 20 # oldest sessions are deleted when exceeded +TELEMETRY_CSV_INTERVAL_S = 5.0 # how often a CSV row is written (seconds) diff --git a/app/logging_setup.py b/app/logging_setup.py new file mode 100644 index 0000000..ebd0742 --- /dev/null +++ b/app/logging_setup.py @@ -0,0 +1,118 @@ +"""Logging initialisation — file + console handlers with session isolation.""" + +from __future__ import annotations + +import logging +import platform +import sys +from pathlib import Path + +from app.config import APP_NAME, APP_VERSION, MAX_LOG_FILES + +_LOG_FORMAT = "%(asctime)s.%(msecs)03d [%(levelname)-7s] %(name)s: %(message)s" +_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +_console_handler: logging.StreamHandler | None = None + + +def setup_logging(log_dir: Path, session_id: str) -> Path: + """ + Configure the root logger for a new session. + + Creates: + /duck-preview_.log — DEBUG level, all messages + console StreamHandler — WARNING by default + + Prunes oldest log files when count exceeds MAX_LOG_FILES. + + Args: + log_dir: Directory where log files are stored. + session_id: Timestamp string used as filename suffix (e.g. "2026-05-12_14-30-00"). + + Returns: + Path to the created log file. + """ + global _console_handler + + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / f"duck-preview_{session_id}.log" + + root = logging.getLogger() + root.setLevel(logging.DEBUG) # handlers filter individually + + formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT) + + # --- File handler — always DEBUG --- + file_handler = logging.FileHandler(log_path, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(formatter) + root.addHandler(file_handler) + + # --- Console handler — WARNING by default, toggled by Debug menu --- + _console_handler = logging.StreamHandler(sys.stdout) + _console_handler.setLevel(logging.WARNING) + _console_handler.setFormatter(formatter) + root.addHandler(_console_handler) + + # Write session header to file + _write_session_header(log_path, session_id) + + # Prune old log files + _prune_old_logs(log_dir, log_path) + + return log_path + + +def set_console_level(debug: bool) -> None: + """Toggle console handler between DEBUG and WARNING (called from Debug menu).""" + if _console_handler is not None: + _console_handler.setLevel(logging.DEBUG if debug else logging.WARNING) + + +def _write_session_header(log_path: Path, session_id: str) -> None: + """Write a human-readable header block at the top of the log file.""" + try: + import PySide6 + pyside_version = PySide6.__version__ + except Exception: + pyside_version = "unknown" + + try: + import psutil + mem = psutil.virtual_memory() + cpu_count = psutil.cpu_count(logical=True) + mem_total_gb = mem.total / (1024 ** 3) + hw_info = f"{cpu_count} logical CPUs, {mem_total_gb:.1f} GB RAM" + except Exception: + hw_info = "unknown" + + lines = [ + "=" * 72, + f" {APP_NAME} {APP_VERSION}", + f" Session: {session_id}", + "=" * 72, + f" Platform : {platform.platform()}", + f" Python : {sys.version.split()[0]}", + f" PySide6 : {pyside_version}", + f" Hardware : {hw_info}", + f" Log file : {log_path.resolve()}", + "=" * 72, + "", + ] + with log_path.open("w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +def _prune_old_logs(log_dir: Path, current: Path) -> None: + """Delete oldest .log files if total count exceeds MAX_LOG_FILES.""" + log_files = sorted( + [p for p in log_dir.glob("duck-preview_*.log") if p != current], + key=lambda p: p.stat().st_mtime, + ) + excess = len(log_files) - (MAX_LOG_FILES - 1) + for path in log_files[:excess]: + try: + path.unlink() + logging.getLogger(__name__).debug("Pruned old log: %s", path.name) + except OSError: + pass diff --git a/app/main.py b/app/main.py index d852165..6a742e6 100644 --- a/app/main.py +++ b/app/main.py @@ -2,22 +2,27 @@ from __future__ import annotations -import logging import sys +from datetime import datetime from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication -from app.config import APP_NAME -from app.ui.main_window import MainWindow +from app.config import APP_NAME, LOG_DIR +from app.logging_setup import setup_logging def main() -> None: - # Basic logging — WARNING by default; Debug menu toggles to DEBUG - logging.basicConfig( - level=logging.WARNING, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - ) + session_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + log_path = setup_logging(LOG_DIR, session_id) + + # Import after logging is ready so module-level loggers work correctly + import logging # noqa: PLC0415 + + from app.ui.main_window import MainWindow # noqa: PLC0415 + + logger = logging.getLogger(__name__) + logger.info("Application starting (session: %s)", session_id) app = QApplication(sys.argv) app.setApplicationName(APP_NAME) @@ -25,9 +30,10 @@ def main() -> None: Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) - window = MainWindow() + window = MainWindow(log_path=log_path) window.show() + logger.info("Application shutting down") sys.exit(app.exec()) diff --git a/app/telemetry/csv_logger.py b/app/telemetry/csv_logger.py new file mode 100644 index 0000000..ed5c7db --- /dev/null +++ b/app/telemetry/csv_logger.py @@ -0,0 +1,104 @@ +"""CsvTelemetryLogger — writes telemetry snapshots to a CSV file with throttling. + +Design decisions: +- Does NOT use the logging module — writes directly via csv.writer so the file + is readable independently of the text log level. +- Flushes after every row so the file is intact even on crash or force-quit. +- Throttle: only one row per TELEMETRY_CSV_INTERVAL_S seconds, even if + metrics_updated fires every 500 ms. This keeps the file manageable for + long sessions (8 h @ 5 s interval = 5 760 rows). +""" + +from __future__ import annotations + +import csv +import time +from datetime import datetime +from pathlib import Path + +from app.config import TELEMETRY_CSV_INTERVAL_S +from app.telemetry.telemetry_collector import TelemetrySnapshot + +_CSV_HEADER = [ + "timestamp", + "fps_got", + "fps_req", + "frame_time_ms", + "dropped_frames", + "cpu_sys_pct", + "cpu_core_pct", + "mem_mb", +] + + +class CsvTelemetryLogger: + """ + Receives TelemetrySnapshot objects and writes throttled rows to a CSV file. + + Usage: + logger = CsvTelemetryLogger(path) + telemetry_collector.metrics_updated.connect(logger.on_metrics_updated) + # call logger.close() on application exit + """ + + def __init__( + self, + path: Path, + interval_s: float = TELEMETRY_CSV_INTERVAL_S, + ) -> None: + self._interval_s = interval_s + self._last_write_time: float = 0.0 + self._rows_written: int = 0 + + path.parent.mkdir(parents=True, exist_ok=True) + self._file = path.open("w", newline="", encoding="utf-8") + self._writer = csv.writer(self._file) + self._writer.writerow(_CSV_HEADER) + self._file.flush() + + # ------------------------------------------------------------------ + # Slot — connect to TelemetryCollector.metrics_updated + # ------------------------------------------------------------------ + + def on_metrics_updated(self, snapshot: TelemetrySnapshot) -> None: + """Write a row if the throttle interval has elapsed.""" + now = time.monotonic() + if now - self._last_write_time < self._interval_s: + return + self._last_write_time = now + self._write_row(snapshot) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Flush and close the CSV file. Call on application shutdown.""" + try: + self._file.flush() + self._file.close() + except OSError: + pass + + @property + def rows_written(self) -> int: + return self._rows_written + + # ------------------------------------------------------------------ + # Private + # ------------------------------------------------------------------ + + def _write_row(self, snap: TelemetrySnapshot) -> None: + ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] # HH:MM:SS.mmm + self._writer.writerow([ + ts, + f"{snap.fps:.1f}", + f"{snap.target_fps:.1f}" if snap.target_fps is not None else "", + f"{snap.frame_time_ms:.2f}", + snap.dropped_frames, + f"{snap.cpu_percent_sys:.1f}", + f"{snap.cpu_percent_core:.1f}", + f"{snap.memory_mb:.1f}" if snap.memory_mb is not None else "", + ]) + self._file.flush() + self._rows_written += 1 diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 86f03d2..591829a 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from pathlib import Path from PySide6.QtCore import QTimer from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar @@ -12,6 +13,7 @@ from app.camera.camera_service import CameraService from app.config import APP_NAME, APP_VERSION from app.overlay.telemetry_overlay import TelemetryOverlay from app.pipeline.frame_dispatcher import FrameDispatcher +from app.telemetry.csv_logger import CsvTelemetryLogger from app.telemetry.telemetry_collector import TelemetryCollector from app.ui.camera_view import CameraView from app.ui.menu_bar import AppMenuBar @@ -32,13 +34,13 @@ class MainWindow(QMainWindow): Signal flow: CameraService.frame_ready → FrameDispatcher.dispatch - → CameraView.on_frame (render frame) - → TelemetryCollector.on_frame (measure metrics) - → TelemetryOverlay.on_metrics_updated (feed overlay data) - (CameraView repaints and calls TelemetryOverlay.paint()) + → CameraView.on_frame (render frame) + → TelemetryCollector.on_frame (measure metrics) + → TelemetryOverlay.on_metrics_updated (overlay data) + → CsvTelemetryLogger.on_metrics_updated (CSV file) """ - def __init__(self) -> None: + def __init__(self, log_path: Path | None = None) -> None: super().__init__() self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") @@ -50,6 +52,13 @@ class MainWindow(QMainWindow): self._dispatcher = FrameDispatcher(self) self._telemetry = TelemetryCollector(parent=self) + # --- CSV telemetry logger --- + self._csv_logger: CsvTelemetryLogger | None = None + if log_path is not None: + csv_path = log_path.with_suffix(".csv") + self._csv_logger = CsvTelemetryLogger(csv_path) + logger.info("Telemetry CSV: %s", csv_path.resolve()) + # --- Camera view (central widget) --- self._camera_view = CameraView(self) self._camera_view.setSizePolicy( @@ -64,6 +73,8 @@ class MainWindow(QMainWindow): # --- Menu bar --- self._menu = AppMenuBar(self) self.setMenuBar(self._menu) + if log_path is not None: + self._menu.set_log_file_path(str(log_path.resolve())) # --- Status bar --- self._status_bar = QStatusBar(self) @@ -111,15 +122,19 @@ class MainWindow(QMainWindow): # CameraService → FrameDispatcher self._camera_service.frame_ready.connect(self._dispatcher.dispatch) - # FrameDispatcher → CameraView (render) — drop if busy: stay fluid + # FrameDispatcher → CameraView (render) — drop if busy self._dispatcher.subscribe(self._camera_view.on_frame, drop_if_busy=True) # FrameDispatcher → TelemetryCollector — never drop, count every frame self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False) - # TelemetryCollector → TelemetryOverlay (data only, no repaint trigger here) + # TelemetryCollector → overlay self._telemetry.metrics_updated.connect(self._telemetry_overlay.on_metrics_updated) + # TelemetryCollector → CSV logger (throttled internally) + if self._csv_logger is not None: + self._telemetry.metrics_updated.connect(self._csv_logger.on_metrics_updated) + # CameraService → TelemetryCollector: keep target FPS in sync self._camera_service.format_changed.connect(self._telemetry.set_target_fps) @@ -171,4 +186,9 @@ class MainWindow(QMainWindow): def closeEvent(self, event) -> None: # noqa: N802 self._camera_service.stop() + if self._csv_logger is not None: + logger.info( + "CSV telemetry: %d rows written", self._csv_logger.rows_written + ) + self._csv_logger.close() super().closeEvent(event) diff --git a/app/ui/menu_bar.py b/app/ui/menu_bar.py index ea36097..b3cddef 100644 --- a/app/ui/menu_bar.py +++ b/app/ui/menu_bar.py @@ -9,6 +9,7 @@ from PySide6.QtGui import QAction, QActionGroup from PySide6.QtWidgets import QMenuBar, QWidget from app.camera.camera_enumerator import CameraInfo +from app.logging_setup import set_console_level logger = logging.getLogger(__name__) @@ -44,7 +45,7 @@ class AppMenuBar(QMenuBar): self._build_menus() # ------------------------------------------------------------------ - # Public API — called after camera enumeration + # Public API # ------------------------------------------------------------------ def populate_cameras(self, cameras: list[CameraInfo]) -> None: @@ -52,7 +53,6 @@ class AppMenuBar(QMenuBar): self._cameras = cameras menu = self._camera_menu - # Remove existing camera actions (keep Reconnect + separator) for action in list(menu.actions()): if action not in (self._reconnect_action, self._cam_separator): menu.removeAction(action) @@ -69,8 +69,7 @@ class AppMenuBar(QMenuBar): action.triggered.connect(self._on_camera_action) if cameras: - first = self._camera_group.actions()[0] - first.setChecked(True) + self._camera_group.actions()[0].setChecked(True) def populate_formats(self, camera_info: CameraInfo) -> None: """Populate Resolution and FPS menus based on a camera's supported formats.""" @@ -78,7 +77,6 @@ class AppMenuBar(QMenuBar): self._populate_fps(camera_info) def set_active_camera(self, camera_info: CameraInfo) -> None: - """Check the menu item matching camera_info.""" if self._camera_group is None: return for action in self._camera_group.actions(): @@ -86,24 +84,31 @@ class AppMenuBar(QMenuBar): action.setChecked(True) return + def set_log_file_path(self, path: str) -> None: + """Display the log file path as a disabled menu item in Debug menu.""" + # Truncate long paths for display + display = path if len(path) <= 60 else "…" + path[-57:] + self._log_file_action.setText(f"Log: {display}") + self._log_file_action.setToolTip(path) + # ------------------------------------------------------------------ # Menu construction # ------------------------------------------------------------------ def _build_menus(self) -> None: - # --- Camera menu --- + # Camera menu self._camera_menu = self.addMenu("Camera") self._cam_separator = self._camera_menu.addSeparator() self._reconnect_action = QAction("Reconnect", self) self._reconnect_action.triggered.connect(self.reconnect_requested) self._camera_menu.addAction(self._reconnect_action) - # --- Video menu --- + # Video menu self._video_menu = self.addMenu("Video") self._res_menu = self._video_menu.addMenu("Resolution") self._fps_menu = self._video_menu.addMenu("FPS") - # --- Debug menu --- + # Debug menu debug_menu = self.addMenu("Debug") self._overlay_action = QAction("Show Overlay", self) @@ -118,20 +123,26 @@ class AppMenuBar(QMenuBar): self._log_action.toggled.connect(self._on_log_toggled) debug_menu.addAction(self._log_action) + debug_menu.addSeparator() + + self._log_file_action = QAction("Log: (not started)", self) + self._log_file_action.setEnabled(False) + debug_menu.addAction(self._log_file_action) + def _populate_resolutions(self, camera_info: CameraInfo) -> None: self._res_menu.clear() self._resolution_group = QActionGroup(self) self._resolution_group.setExclusive(True) seen: set[tuple[int, int]] = set() - for w, h, _ in camera_info.formats: - key = (w, h) + for fmt in camera_info.formats: + key = (fmt.width, fmt.height) if key in seen: continue seen.add(key) - action = QAction(f"{w} × {h}", self) + action = QAction(f"{fmt.width} × {fmt.height}", self) action.setCheckable(True) - action.setData((w, h)) + action.setData((fmt.width, fmt.height)) self._resolution_group.addAction(action) self._res_menu.addAction(action) action.triggered.connect(self._on_resolution_action) @@ -146,14 +157,14 @@ class AppMenuBar(QMenuBar): self._fps_group.setExclusive(True) seen: set[int] = set() - for _, _, fps in camera_info.formats: - key = round(fps) + for fmt in camera_info.formats: + key = round(fmt.max_fps) if key in seen: continue seen.add(key) action = QAction(f"{key} fps", self) action.setCheckable(True) - action.setData(float(fps)) + action.setData(float(fmt.max_fps)) self._fps_group.addAction(action) self._fps_menu.addAction(action) action.triggered.connect(self._on_fps_action) @@ -193,6 +204,5 @@ class AppMenuBar(QMenuBar): self.fps_selected.emit(fps) def _on_log_toggled(self, enabled: bool) -> None: - level = logging.DEBUG if enabled else logging.WARNING - logging.getLogger().setLevel(level) + set_console_level(enabled) self.log_toggled.emit(enabled)