summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-07-29 13:40:09 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-07-29 13:40:09 -0600
commit23800a32231acecbace12bfc41c03b081b1bf565 (patch)
tree213caf20b68b95ba674cf29d4e806a9b59aadb0d
parent2c6b4b751ffdd1156254b31745597654b5f2e286 (diff)
parentcf33095f77e6ff902fb69932fa704eb6192b3418 (diff)
Merge branch 'feat/dev-debug-window'
-rw-r--r--core/app_settings.py19
-rw-r--r--core/debug_log.py65
-rw-r--r--devices/analog_input.py2
-rw-r--r--devices/arduino_device.py2
-rw-r--r--devices/digital_io.py2
-rw-r--r--devices/nidaqmx_device.py2
-rw-r--r--devices/serial_device.py6
-rw-r--r--main.py2
-rw-r--r--ui/add_device_dialog.py2
-rw-r--r--ui/main_window.py31
-rw-r--r--ui/windows/debug_window.py65
-rw-r--r--ui/windows/settings_window.py11
12 files changed, 207 insertions, 2 deletions
diff --git a/core/app_settings.py b/core/app_settings.py
index e8bc7ae..ea3ac49 100644
--- a/core/app_settings.py
+++ b/core/app_settings.py
@@ -51,3 +51,22 @@ def save_settings(cfg: dict) -> None:
json.dump(cfg, f, indent=2)
except Exception:
pass
+
+
+# ── Developer mode ────────────────────────────────────────────────────────
+#
+# In-memory cache so widgets that build device/config UI (Add Device dialog,
+# per-device config panels, plugin panels) can check this without needing
+# the full settings dict threaded through their constructors. MainWindow
+# keeps it in sync with the persisted setting whenever settings are
+# loaded/applied — see set_developer_mode() calls in ui/main_window.py.
+_dev_mode = True
+
+
+def is_developer_mode() -> bool:
+ return _dev_mode
+
+
+def set_developer_mode(value: bool) -> None:
+ global _dev_mode
+ _dev_mode = value
diff --git a/core/debug_log.py b/core/debug_log.py
new file mode 100644
index 0000000..86a8a59
--- /dev/null
+++ b/core/debug_log.py
@@ -0,0 +1,65 @@
+"""
+core/debug_log.py
+
+Tees stdout/stderr into an in-memory ring buffer + Qt signal so the Debug
+window can show everything the app has printed since startup — including
+messages from background poll threads (e.g. "[CMLLayer] poll error: ...") —
+not just whatever gets printed while the window happens to be open.
+
+install() should be called once, early, before anything prints. The real
+streams are still written to, so running from a terminal is unaffected.
+"""
+
+from __future__ import annotations
+
+import sys
+from collections import deque
+from typing import Optional
+
+from PyQt6.QtCore import QObject, pyqtSignal
+
+
+class _Broadcaster(QObject):
+ line_written = pyqtSignal(str)
+
+
+class _StreamTee:
+ def __init__(self, real_stream, lines: deque, broadcaster: _Broadcaster):
+ self._real = real_stream
+ self._lines = lines
+ self._broadcaster = broadcaster
+
+ def write(self, text: str) -> None:
+ self._real.write(text)
+ if text:
+ self._lines.append(text)
+ self._broadcaster.line_written.emit(text)
+
+ def flush(self) -> None:
+ self._real.flush()
+
+ def isatty(self) -> bool:
+ return False
+
+
+_lines: Optional[deque] = None
+_broadcaster: Optional[_Broadcaster] = None
+
+
+def install(max_lines: int = 2000) -> None:
+ """Redirect sys.stdout/sys.stderr through the tee. Safe to call once."""
+ global _lines, _broadcaster
+ if _broadcaster is not None:
+ return
+ _lines = deque(maxlen=max_lines)
+ _broadcaster = _Broadcaster()
+ sys.stdout = _StreamTee(sys.stdout, _lines, _broadcaster)
+ sys.stderr = _StreamTee(sys.stderr, _lines, _broadcaster)
+
+
+def get_broadcaster() -> Optional[_Broadcaster]:
+ return _broadcaster
+
+
+def get_history() -> str:
+ return "".join(_lines) if _lines is not None else ""
diff --git a/devices/analog_input.py b/devices/analog_input.py
index c9e5d2a..6ac82d4 100644
--- a/devices/analog_input.py
+++ b/devices/analog_input.py
@@ -250,6 +250,8 @@ class AnalogInputConfigWidget(QWidget):
self.sim_check = QCheckBox("Simulation Mode (no hardware)")
self.sim_check.setChecked(self.device.simulate)
+ from core.app_settings import is_developer_mode
+ self.sim_check.setVisible(is_developer_mode())
be_form.addRow(self.sim_check)
root.addWidget(be_grp)
diff --git a/devices/arduino_device.py b/devices/arduino_device.py
index 7c1d21b..954d387 100644
--- a/devices/arduino_device.py
+++ b/devices/arduino_device.py
@@ -287,6 +287,8 @@ class ArduinoConfigWidget(QWidget):
self.sim_chk = QCheckBox("Simulation Mode (no hardware)")
self.sim_chk.setChecked(self.device.simulate)
+ from core.app_settings import is_developer_mode
+ self.sim_chk.setVisible(is_developer_mode())
ser_form.addRow(self.sim_chk)
scan_row = QHBoxLayout()
diff --git a/devices/digital_io.py b/devices/digital_io.py
index 53395dc..2bd0243 100644
--- a/devices/digital_io.py
+++ b/devices/digital_io.py
@@ -290,6 +290,8 @@ class DigitalIOConfigWidget(QWidget):
be_form.addRow("Backend:", self.be_cb)
self.sim_chk = QCheckBox("Simulate")
self.sim_chk.setChecked(self.device.simulate)
+ from core.app_settings import is_developer_mode
+ self.sim_chk.setVisible(is_developer_mode())
be_form.addRow(self.sim_chk)
self.ni_dev_edit = QLineEdit(self.device._ni_device)
diff --git a/devices/nidaqmx_device.py b/devices/nidaqmx_device.py
index 88bb932..3f64998 100644
--- a/devices/nidaqmx_device.py
+++ b/devices/nidaqmx_device.py
@@ -294,6 +294,8 @@ class NidaqmxConfigWidget(QWidget):
self.sim_chk = QCheckBox("Simulation Mode (no hardware)")
self.sim_chk.setChecked(self.device.simulate)
+ from core.app_settings import is_developer_mode
+ self.sim_chk.setVisible(is_developer_mode())
ni_form.addRow(self.sim_chk)
scan_row = QHBoxLayout()
diff --git a/devices/serial_device.py b/devices/serial_device.py
index 067656a..bd953b8 100644
--- a/devices/serial_device.py
+++ b/devices/serial_device.py
@@ -448,7 +448,11 @@ class SerialConfigWidget(QWidget):
self._sim_cb = QComboBox()
self._sim_cb.addItems(["Simulate", "Real Hardware"])
self._sim_cb.setCurrentIndex(0 if self.device.simulate else 1)
- conn_form.addRow("Mode:", self._sim_cb)
+ from core.app_settings import is_developer_mode
+ mode_lbl = QLabel("Mode:")
+ dev_mode = is_developer_mode()
+ mode_lbl.setVisible(dev_mode); self._sim_cb.setVisible(dev_mode)
+ conn_form.addRow(mode_lbl, self._sim_cb)
root.addWidget(conn_grp)
diff --git a/main.py b/main.py
index b023b99..ede6277 100644
--- a/main.py
+++ b/main.py
@@ -13,11 +13,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PyQt6.QtWidgets import QApplication
from ui.main_window import MainWindow
+from core.debug_log import install as install_debug_log
def main():
app = QApplication(sys.argv)
app.setApplicationName("LabDAQ")
+ install_debug_log() # tee stdout/stderr for the Debug window, before anything prints
qss = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "style_dark.qss")
if os.path.exists(qss):
diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py
index 2956a96..4b2b373 100644
--- a/ui/add_device_dialog.py
+++ b/ui/add_device_dialog.py
@@ -17,6 +17,7 @@ from devices.device_registry import DeviceRegistry
from devices.arduino_device import ArduinoDevice
from devices.nidaqmx_device import NidaqmxDevice
from devices.serial_device import SerialDevice, _FORMAT_LABELS
+from core.app_settings import is_developer_mode
# ── Background scan threads ───────────────────────────────────────────────────
@@ -276,6 +277,7 @@ class AddDeviceDialog(QDialog):
conn_form.addRow(self._ni_lbl, self._ni_edit)
self._sim_chk = QCheckBox("Simulation mode")
+ self._sim_chk.setVisible(is_developer_mode()) # dev-mode-only escape hatch
conn_form.addRow(self._sim_chk)
root.addLayout(conn_form)
diff --git a/ui/main_window.py b/ui/main_window.py
index d0e0ac4..ca4274c 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -45,7 +45,7 @@ from ui.windows.settings_window import SettingsWindow
from plugins.plugin_manager import PluginManager
from plugins.base_plugin import PluginContext
-from core.app_settings import load_settings, save_settings
+from core.app_settings import load_settings, save_settings, set_developer_mode
_DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss")
@@ -68,6 +68,7 @@ class MainWindow(QMainWindow):
self._win_channels = None
self._win_plot = None
self._win_settings = None
+ self._win_debug = None
_plugins_dir = os.path.join(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))), "plugins")
@@ -171,6 +172,12 @@ class MainWindow(QMainWindow):
set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn))
tb.addWidget(set_btn); self._btn_settings = set_btn
+ debug_btn = QPushButton("🐞 Debug")
+ debug_btn.setObjectName("toolbarSectionBtn"); debug_btn.setCheckable(True)
+ debug_btn.clicked.connect(lambda c: self._toggle_win("debug", c, debug_btn))
+ tb.addWidget(debug_btn); self._btn_debug = debug_btn
+ debug_btn.setVisible(False) # shown/hidden per developer-mode setting
+
# ── Central ───────────────────────────────────────────────────────
central = QWidget(); self.setCentralWidget(central)
@@ -314,12 +321,14 @@ class MainWindow(QMainWindow):
"channels": self._open_channels,
"plot": self._open_plot,
"settings": self._open_settings,
+ "debug": self._open_debug,
}
wins = {
"devices": "_win_devices",
"channels": "_win_channels",
"plot": "_win_plot",
"settings": "_win_settings",
+ "debug": "_win_debug",
}
if checked:
creators[name]()
@@ -370,6 +379,22 @@ class MainWindow(QMainWindow):
self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False))
self._show_win(self._win_settings, "right")
+ def _open_debug(self):
+ from ui.windows.debug_window import DebugWindow
+ if self._win_debug is None:
+ self._win_debug = DebugWindow(self)
+ self._win_debug.closed.connect(lambda: self._btn_debug.setChecked(False))
+ self._show_win(self._win_debug, "right")
+
+ def _update_debug_btn_visibility(self):
+ from core.app_settings import is_developer_mode
+ on = is_developer_mode()
+ self._btn_debug.setVisible(on)
+ if not on:
+ self._btn_debug.setChecked(False)
+ if self._win_debug:
+ self._win_debug.hide()
+
def _show_win(self, win: QWidget, position: str = "right"):
if not win.isVisible():
screen = QApplication.screenAt(self.geometry().center())
@@ -554,10 +579,14 @@ class MainWindow(QMainWindow):
def _apply_settings_on_startup(self):
self._apply_theme(self._settings.get("theme", "dark"))
self.engine._interval = self._settings.get("poll_ms", 100) / 1000.0
+ set_developer_mode(self._settings.get("developer_mode", True))
+ self._update_debug_btn_visibility()
def _on_settings(self, cfg: dict):
self._settings.update(cfg)
self.engine._interval = cfg.get("poll_ms", 100) / 1000.0
+ set_developer_mode(self._settings.get("developer_mode", True))
+ self._update_debug_btn_visibility()
save_settings(self._settings)
def _tick(self):
diff --git a/ui/windows/debug_window.py b/ui/windows/debug_window.py
new file mode 100644
index 0000000..0e891ed
--- /dev/null
+++ b/ui/windows/debug_window.py
@@ -0,0 +1,65 @@
+"""
+ui/windows/debug_window.py
+
+DEBUG window — developer-mode only.
+
+Minimal first pass: a live console showing everything the app has printed
+via core.debug_log (stdout/stderr tee), so debugging doesn't require a
+terminal. Not wired to any other diagnostics yet — extend as needed.
+"""
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QTextEdit, QFrame,
+)
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QFont, QCloseEvent
+
+from core.debug_log import get_broadcaster, get_history
+
+
+class DebugWindow(QWidget):
+ closed = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool)
+ self.setWindowTitle("Debug")
+ self.setMinimumSize(560, 420)
+ self.resize(700, 500)
+ self._build()
+
+ broadcaster = get_broadcaster()
+ if broadcaster is not None:
+ broadcaster.line_written.connect(self._append)
+
+ def _build(self):
+ root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0)
+
+ hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44)
+ hl = QHBoxLayout(hdr); hl.setContentsMargins(14, 0, 14, 0)
+ title = QLabel("DEBUG"); title.setObjectName("devWindowTitle")
+ hl.addWidget(title, 1)
+ clear_btn = QPushButton("Clear"); clear_btn.setObjectName("configButton")
+ clear_btn.clicked.connect(lambda: self._console.clear())
+ hl.addWidget(clear_btn)
+ root.addWidget(hdr)
+
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider"); root.addWidget(div)
+
+ self._console = QTextEdit(); self._console.setObjectName("codeEditor")
+ self._console.setReadOnly(True)
+ mono = QFont("IBM Plex Mono, Consolas, Monospace")
+ mono.setStyleHint(QFont.StyleHint.Monospace)
+ self._console.setFont(mono)
+ self._console.setPlainText(get_history())
+ self._console.verticalScrollBar().setValue(self._console.verticalScrollBar().maximum())
+ root.addWidget(self._console, 1)
+
+ def _append(self, text: str):
+ self._console.insertPlainText(text)
+ sb = self._console.verticalScrollBar()
+ sb.setValue(sb.maximum())
+
+ def closeEvent(self, e: QCloseEvent):
+ self.closed.emit(); e.accept()
diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py
index 1630efa..e24da7f 100644
--- a/ui/windows/settings_window.py
+++ b/ui/windows/settings_window.py
@@ -40,6 +40,7 @@ class SettingsWindow(QWidget):
"show_grid": True,
"font_size": 12,
"antialias": True,
+ "developer_mode": True,
}
def __init__(self, registry: DeviceRegistry,
@@ -108,6 +109,14 @@ class SettingsWindow(QWidget):
self._aa_chk = QCheckBox(); self._aa_chk.setChecked(self.cfg["antialias"])
lay.addRow("Anti-alias plots:", self._aa_chk)
+ self._dev_chk = QCheckBox()
+ self._dev_chk.setChecked(self.cfg["developer_mode"])
+ self._dev_chk.setToolTip(
+ "Controls whether the Debug window and each device's Simulation\n"
+ "Mode option are available. Off = real-hardware-only, no debug tools."
+ )
+ lay.addRow("Developer mode:", self._dev_chk)
+
rst = QPushButton("Reset to Defaults"); rst.setObjectName("configButton")
rst.clicked.connect(self._reset_to_defaults)
lay.addRow("", rst)
@@ -120,6 +129,7 @@ class SettingsWindow(QWidget):
self._theme_cb.setCurrentText(self.cfg["theme"].title())
self._font_sp.setValue(self.cfg["font_size"])
self._aa_chk.setChecked(self.cfg["antialias"])
+ self._dev_chk.setChecked(self.cfg["developer_mode"])
self._poll_sp.setValue(self.cfg["poll_ms"])
self._buf_sp.setValue(self.cfg["buffer_size"])
self._log_edit.setText(self.cfg["log_dir"])
@@ -288,6 +298,7 @@ class SettingsWindow(QWidget):
"theme": theme,
"font_size": self._font_sp.value(),
"antialias": self._aa_chk.isChecked(),
+ "developer_mode": self._dev_chk.isChecked(),
"poll_ms": self._poll_sp.value(),
"buffer_size": self._buf_sp.value(),
"log_dir": self._log_edit.text(),