summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-08-02 01:34:43 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-08-02 01:34:43 -0600
commita3aa1df99df8f413cac2ba6020b7cd0dec6d2390 (patch)
treea4c5feca6b0db326d9e54f417abc132c1270a7a3
parentf5066a8ca2fb50aa3dddf2c8847e52574cdde6ad (diff)
parentf1aaffbc3eb1e2c154315c556d2555803eea7997 (diff)
Merge origin/main: reconcile ConfigWindow consolidation with Debug window + protocol updates
origin/main (23 commits) added a Debug window/log system on top of the old separate Devices/Channels/Plot windows, plus protocol fixes (cml, mark10, modbus_rtu, scpi) and device/profile changes. Local main (3 commits) replaced the separate windows with a unified ConfigWindow + dock panel. Kept local's ConfigWindow/dock architecture and ported the Debug window onto it: new toolbar button + _open_debug(), gated by developer_mode same as origin's version. Deduped the two independent "developer mode" toggles that had collided in SettingsWindow (origin's General-tab checkbox gating Debug window + device sim-mode visibility, local's Advanced-tab checkbox setting verbose logging) into one General-tab checkbox that does both. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--.gitignore2
-rw-r--r--api_layers/protocols/base_protocol.py1
-rw-r--r--api_layers/protocols/cml.py120
-rw-r--r--api_layers/protocols/mark10.py32
-rw-r--r--api_layers/protocols/modbus_rtu.py18
-rw-r--r--api_layers/protocols/scpi.py15
-rw-r--r--core/app_settings.py19
-rw-r--r--core/debug_log.py65
-rw-r--r--core/profile.py9
-rw-r--r--devices/analog_input.py3
-rw-r--r--devices/arduino_device.py4
-rw-r--r--devices/base_device.py1
-rw-r--r--devices/digital_io.py4
-rw-r--r--devices/nidaqmx_device.py4
-rw-r--r--devices/serial_device.py65
-rw-r--r--docs/CM1-C_ASCII_Command_Cheatsheet.md189
-rw-r--r--main.py2
-rw-r--r--plugins/motion_capture/device.py1
-rw-r--r--plugins/plugin_manager.py40
-rw-r--r--requirements.txt7
-rw-r--r--ui/add_device_dialog.py10
-rw-r--r--ui/config_dialog.py9
-rw-r--r--ui/control_editor.py8
-rw-r--r--ui/control_panel.py61
-rw-r--r--ui/main_window.py96
-rw-r--r--ui/plot_builder.py8
-rw-r--r--ui/plot_config.py4
-rw-r--r--ui/style_dark.qss2
-rw-r--r--ui/style_light.qss2
-rw-r--r--ui/windows/channels_window.py20
-rw-r--r--ui/windows/debug_window.py65
-rw-r--r--ui/windows/devices_window.py27
-rw-r--r--ui/windows/plot_window.py16
-rw-r--r--ui/windows/settings_window.py45
34 files changed, 836 insertions, 138 deletions
diff --git a/.gitignore b/.gitignore
index 4063f46..b4f9947 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,5 @@ scripts/release/keystore/
# Claude project memory
.claude/
+.claude/settings.local.json
+plugins/enabled.json
diff --git a/api_layers/protocols/base_protocol.py b/api_layers/protocols/base_protocol.py
index 096151d..bb45d6b 100644
--- a/api_layers/protocols/base_protocol.py
+++ b/api_layers/protocols/base_protocol.py
@@ -30,6 +30,7 @@ class BaseProtocol(ABC):
self._cache: Dict[str, float] = {}
self._lock = threading.Lock()
+ self._io_lock = threading.Lock() # guards self._ser — shared by poll thread and write() callers
self._running = False
self._thread: Optional[threading.Thread] = None
self._ser = None
diff --git a/api_layers/protocols/cml.py b/api_layers/protocols/cml.py
index dbee2fa..113afe7 100644
--- a/api_layers/protocols/cml.py
+++ b/api_layers/protocols/cml.py
@@ -1,36 +1,38 @@
"""
api_layers/protocols/cml.py
-CoolMuscle Language (CML) protocol layer.
+CoolMuscle CM1-C ASCII (CML) protocol layer.
-For CoolMuscle CM-series servo motors over RS-232 (single axis)
-or RS-485 (multi-drop, up to 31 axes).
+Wire format — see docs/CM1-C_ASCII_Command_Cheatsheet.md:
+ <CMD>.<motor_id>[=<value>]\\r
+ e.g. "S0.1=20\\r" (set speed register), "?97.1\\r" (query speed),
+ "^.1\\r" (execute), "(.1\\r" (enable), ").1\\r" (disable), "].1\\r" (stop)
-Frame format
-─────────────
- RS-232 (address=0):
- Command: <CMD>[<data>]\\r
- Response: <CMD>[<data>]\\r (echo-back)
+Motor ID is always appended explicitly as ".<id>" on every command — there is
+no address-less / prefix-free mode.
- RS-485 (address 1-31):
- Command: #<addr><CMD>[<data>]\\r
- Response: *<addr><CMD>[<data>]\\r
+Terminator is CR only (default K70 setting). Replies are read up to that CR
+with Serial.read_until(b"\\r") rather than readline(), since pyserial's
+readline() looks for LF by default and would otherwise block for the full
+serial timeout on every single query.
Read commands (query current state):
- TP — Tell absolute position (encoder counts, signed)
- TV — Tell velocity (counts/second, signed)
- TC — Tell current (% rated × 10, unsigned)
- TS — Tell status word (hex flags)
+ TP — current position (?96, pulses, signed)
+ TV — current speed (?97, unit set by K37, signed)
+ TC — averaged current (?98, % rated, unsigned)
+ TS — motor status (?99, bit field — see cheatsheet)
Write commands (control):
- ME — Motor Enable
- MD — Motor Disable
- MA<value> — Move Absolute (encoder counts)
- MR<value> — Move Relative (encoder counts)
- VS<value> — Velocity Setpoint
+ ME — Motor Enable ("(")
+ MD — Motor Disable (")")
+ ST — Immediate stop ("]")
+ VS<value> — Velocity setpoint: sets a large P0 target (direction via sign
+ of S0) then executes with "^" — continuous rotation until
+ stopped (S0=0 or ST/MD).
+ MA<value> — Move absolute: sets P0 to <value> then executes with "^".
Channel IDs follow pattern: <motor_id>_<CMD>
- e.g. "M1_TP", "M1_TV", "M2_TC"
+ e.g. "M1_TP", "M1_VS", "M2_TC"
"""
import math
@@ -41,15 +43,19 @@ from typing import Dict, List, Optional
from api_layers.protocols.base_protocol import BaseProtocol
-_READ_CMDS = ["TP", "TV", "TC", "TS"]
-_RESP_RE = re.compile(r"[*@]?\d*([A-Z]{2})([\s\S]*)")
-_NUM_RE = re.compile(r"[+-]?\d+")
+_QUERY_CMDS = {"TP": "?96", "TV": "?97", "TC": "?98", "TS": "?99"}
+_NUM_RE = re.compile(r"[+-]?\d+\.?\d*")
+
+# Position target for velocity-mode continuous rotation — direction comes
+# from the sign of S0, not this value, so it just needs to be far enough
+# away that the move never completes on its own.
+_CONTINUOUS_POSITION = 1_000_000_000
@dataclass
class CMLMotor:
motor_id: str
- address: int = 1 # 0 = RS-232 (no address prefix)
+ address: int = 1 # CM1-C motor ID, always sent as ".<address>"
read_cmds: List[str] = field(default_factory=lambda: ["TP", "TV", "TC"])
@@ -66,16 +72,27 @@ class CMLLayer(BaseProtocol):
super().__init__(port, baud, poll_interval, simulate)
self.motors = motors or [CMLMotor("M1", address=1)]
- # ── Protocol ──────────────────────────────────────────────────────────
+ # ── Low-level I/O ─────────────────────────────────────────────────────
- def _send_query(self, motor: CMLMotor, cmd: str) -> Optional[float]:
- if motor.address == 0:
- frame = f"{cmd}\r".encode()
- else:
- frame = f"#{motor.address}{cmd}\r".encode()
+ def _send(self, address: int, cmd: str, value: Optional[int] = None) -> None:
+ suffix = f"={value}" if value is not None else ""
+ frame = f"{cmd}.{address}{suffix}\r".encode()
+ self._ser.reset_input_buffer()
self._ser.write(frame)
self._ser.flush()
- resp = self._ser.readline().decode(errors="replace").strip()
+
+ def _read_reply(self) -> str:
+ return self._ser.read_until(b"\r").decode(errors="replace").strip()
+
+ # ── Protocol ──────────────────────────────────────────────────────────
+
+ def _send_query(self, motor: CMLMotor, cmd: str) -> Optional[float]:
+ query = _QUERY_CMDS.get(cmd)
+ if query is None:
+ return None
+ with self._io_lock:
+ self._send(motor.address, query)
+ resp = self._read_reply()
return _parse_cml_response(resp)
def _poll(self) -> Dict[str, float]:
@@ -110,8 +127,7 @@ class CMLLayer(BaseProtocol):
return result
def write(self, channel_id: str, value) -> bool:
- # channel_id: "<motor_id>_<CMD>[<data>]"
- # e.g. "M1_ME", "M1_MA" (value carries position)
+ # channel_id: "<motor_id>_<CMD>" e.g. "M1_ME", "M1_VS" (value carries speed)
parts = channel_id.split("_", 1)
if len(parts) != 2:
return False
@@ -119,21 +135,41 @@ class CMLLayer(BaseProtocol):
motor = next((m for m in self.motors if m.motor_id == motor_id), None)
if motor is None:
return False
+ if self.simulate:
+ return True
try:
- data = "" if cmd in ("ME", "MD") else str(int(value))
- if motor.address == 0:
- frame = f"{cmd}{data}\r".encode()
- else:
- frame = f"#{motor.address}{cmd}{data}\r".encode()
- self._ser.write(frame)
+ with self._io_lock:
+ if cmd == "ME":
+ self._send(motor.address, "(")
+ self._read_reply()
+ elif cmd == "MD":
+ self._send(motor.address, ")")
+ self._read_reply()
+ elif cmd == "ST":
+ self._send(motor.address, "]")
+ self._read_reply()
+ elif cmd == "VS":
+ self._send(motor.address, "P0", _CONTINUOUS_POSITION)
+ self._read_reply()
+ self._send(motor.address, "S0", int(value))
+ self._read_reply()
+ self._send(motor.address, "^")
+ self._read_reply()
+ elif cmd == "MA":
+ self._send(motor.address, "P0", int(value))
+ self._read_reply()
+ self._send(motor.address, "^")
+ self._read_reply()
+ else:
+ return False
return True
except Exception:
return False
def _parse_cml_response(resp: str) -> Optional[float]:
- """Extract numeric value from CML response like '*1TP+001234'."""
- m = _NUM_RE.search(resp[3:] if resp and resp[0] in "*@#" else resp)
+ """Extract the first numeric value from a CM1-C reply."""
+ m = _NUM_RE.search(resp)
if m:
return float(m.group())
return None
diff --git a/api_layers/protocols/mark10.py b/api_layers/protocols/mark10.py
index 1b5bf1c..48ed927 100644
--- a/api_layers/protocols/mark10.py
+++ b/api_layers/protocols/mark10.py
@@ -10,6 +10,11 @@ Commands (sent with CR terminator):
Response format: "+0.1234 kgF" (sign, value, space, unit suffix)
+Replies are read up to CR with Serial.read_until(b"\\r") rather than
+readline(), since pyserial's readline() looks for LF by default and the
+gauge only terminates with CR — readline() would otherwise block for the
+full serial timeout on every poll.
+
Channels exposed:
force — current force reading (in instrument's selected unit)
unit_code — numeric index into UNITS list (lb=0, kgF=1, N=2, ozF=3)
@@ -39,12 +44,20 @@ class Mark10Layer(BaseProtocol):
super().__init__(port, baud, poll_interval, simulate)
self._unit = "N"
+ @property
+ def current_unit(self) -> str:
+ """Last unit suffix seen in a gauge reply (e.g. "N", "kgF")."""
+ return self._unit
+
# ── Protocol ──────────────────────────────────────────────────────────
def _poll(self) -> Dict[str, float]:
try:
- self._ser.write(b"?\r")
- resp = self._ser.readline().decode(errors="replace").strip()
+ with self._io_lock:
+ self._ser.reset_input_buffer()
+ self._ser.write(b"?\r")
+ self._ser.flush()
+ resp = self._ser.read_until(b"\r").decode(errors="replace").strip()
return self._parse(resp)
except Exception:
return {}
@@ -76,10 +89,15 @@ class Mark10Layer(BaseProtocol):
"cycle_units": b"U\r",
}
cmd = cmd_map.get(channel_id)
- if cmd is None or not self._ser:
+ if cmd is None:
+ return False
+ if self.simulate:
+ return True
+ if not self._ser:
return False
try:
- self._ser.write(cmd)
+ with self._io_lock:
+ self._ser.write(cmd)
return True
except Exception:
return False
@@ -88,8 +106,10 @@ class Mark10Layer(BaseProtocol):
def zero(self) -> None:
if self._ser:
- self._ser.write(b"Z\r")
+ with self._io_lock:
+ self._ser.write(b"Z\r")
def cycle_units(self) -> None:
if self._ser:
- self._ser.write(b"U\r")
+ with self._io_lock:
+ self._ser.write(b"U\r")
diff --git a/api_layers/protocols/modbus_rtu.py b/api_layers/protocols/modbus_rtu.py
index 9d371b1..33f6877 100644
--- a/api_layers/protocols/modbus_rtu.py
+++ b/api_layers/protocols/modbus_rtu.py
@@ -91,10 +91,11 @@ class ModbusRTULayer(BaseProtocol):
def _read_registers(self, fc: int, start: int, count: int) -> List[int]:
req = _frame(struct.pack(">BBHH", self.slave_addr, fc, start, count))
- self._ser.write(req)
- time.sleep(0.005)
- n_bytes = 5 + 2 * count
- resp = self._ser.read(n_bytes)
+ with self._io_lock:
+ self._ser.write(req)
+ time.sleep(0.005)
+ n_bytes = 5 + 2 * count
+ resp = self._ser.read(n_bytes)
if len(resp) < n_bytes:
raise IOError(f"Short response {len(resp)}/{n_bytes} bytes")
crc_recv = struct.unpack("<H", resp[-2:])[0]
@@ -142,15 +143,18 @@ class ModbusRTULayer(BaseProtocol):
def _write_register(self, register: int, value: int) -> bool:
req = _frame(struct.pack(">BBHH", self.slave_addr, 0x06, register, value & 0xFFFF))
- self._ser.write(req)
- time.sleep(0.005)
- resp = self._ser.read(8)
+ with self._io_lock:
+ self._ser.write(req)
+ time.sleep(0.005)
+ resp = self._ser.read(8)
return len(resp) == 8
def write(self, channel_id: str, value) -> bool:
ch = next((c for c in self.channels if c.channel_id == channel_id), None)
if ch is None:
return False
+ if self.simulate:
+ return True
try:
int_val = round((float(value) - ch.offset) / ch.scale)
return self._write_register(ch.register, int_val)
diff --git a/api_layers/protocols/scpi.py b/api_layers/protocols/scpi.py
index 730f8e0..fca5ba4 100644
--- a/api_layers/protocols/scpi.py
+++ b/api_layers/protocols/scpi.py
@@ -54,8 +54,9 @@ class SCPILayer(BaseProtocol):
if not ch.query.strip():
continue
try:
- self._ser.write(f"{ch.query.strip()}\n".encode())
- resp = self._ser.readline().decode(errors="replace").strip()
+ with self._io_lock:
+ self._ser.write(f"{ch.query.strip()}\n".encode())
+ resp = self._ser.readline().decode(errors="replace").strip()
val = _parse_numeric(resp)
if val is not None:
result[ch.channel_id] = val * ch.scale
@@ -73,9 +74,12 @@ class SCPILayer(BaseProtocol):
ch = next((c for c in self.channels if c.channel_id == channel_id), None)
if ch is None or not ch.write_cmd:
return False
+ if self.simulate:
+ return True
try:
cmd = ch.write_cmd.format(value=value)
- self._ser.write(f"{cmd}\n".encode())
+ with self._io_lock:
+ self._ser.write(f"{cmd}\n".encode())
return True
except Exception:
return False
@@ -85,8 +89,9 @@ class SCPILayer(BaseProtocol):
if not self._ser:
return "(not connected)"
try:
- self._ser.write(b"*IDN?\n")
- return self._ser.readline().decode(errors="replace").strip()
+ with self._io_lock:
+ self._ser.write(b"*IDN?\n")
+ return self._ser.readline().decode(errors="replace").strip()
except Exception as e:
return f"(error: {e})"
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/core/profile.py b/core/profile.py
index d3c7e3a..7ede859 100644
--- a/core/profile.py
+++ b/core/profile.py
@@ -302,8 +302,15 @@ class ProfileManager:
print(f"[Profile] Unknown device type: {dev_type}")
continue
try:
- kwargs = {k: v for k, v in dev_cfg.items() if k != "device_type"}
+ # "name" is a display label, not a constructor arg — every device
+ # factory builds its own default name internally, so apply it
+ # after construction instead of passing it through.
+ custom_name = dev_cfg.get("name")
+ kwargs = {k: v for k, v in dev_cfg.items()
+ if k not in ("device_type", "name")}
dev = factory(**kwargs)
+ if custom_name:
+ dev.info.name = custom_name
registry.add_instance(dev)
dev.connect()
if engine is not None:
diff --git a/devices/analog_input.py b/devices/analog_input.py
index c9e5d2a..8938bf2 100644
--- a/devices/analog_input.py
+++ b/devices/analog_input.py
@@ -175,6 +175,7 @@ class AnalogInputDevice(BaseDevice):
return {
"device_type": self.DEVICE_TYPE,
"device_id": self.info.device_id,
+ "name": self.info.name,
"num_channels": self._num_channels,
"simulate": self.simulate,
"backend": self.backend,
@@ -250,6 +251,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..2f5e8e6 100644
--- a/devices/arduino_device.py
+++ b/devices/arduino_device.py
@@ -108,6 +108,7 @@ class ArduinoDevice(BaseDevice):
channels.append(ChannelConfig(
channel_id=f"do{i}", name=f"DO {i}", unit="",
min_value=0.0, max_value=1.0,
+ writable=True,
color=_DO_COLORS[i % len(_DO_COLORS)],
))
@@ -203,6 +204,7 @@ class ArduinoDevice(BaseDevice):
return {
"device_type": self.DEVICE_TYPE,
"device_id": self.info.device_id,
+ "name": self.info.name,
"analog_pins": self._analog_pins,
"di_pins": self._di_pins,
"do_pins": self._do_pins,
@@ -287,6 +289,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/base_device.py b/devices/base_device.py
index aa9f7f2..9803867 100644
--- a/devices/base_device.py
+++ b/devices/base_device.py
@@ -30,6 +30,7 @@ class ChannelConfig:
enabled: bool = True
is_output: bool = False
color: str = "#00d4ff"
+ writable: bool = False # True if this channel accepts write_channel() calls
extra: Dict[str, Any] = field(default_factory=dict)
diff --git a/devices/digital_io.py b/devices/digital_io.py
index 53395dc..7cda61f 100644
--- a/devices/digital_io.py
+++ b/devices/digital_io.py
@@ -60,6 +60,7 @@ class DigitalIODevice(BaseDevice):
channel_id=f"do{i}", name=f"DO {i}", unit="",
min_value=0.0, max_value=1.0,
color=_OUT_COLORS[i % len(_OUT_COLORS)],
+ writable=True,
))
info = DeviceInfo(
@@ -258,6 +259,7 @@ class DigitalIODevice(BaseDevice):
return {
"device_type": self.DEVICE_TYPE,
"device_id": self.info.device_id,
+ "name": self.info.name,
"num_inputs": self._num_inputs,
"num_outputs": self._num_outputs,
"simulate": self.simulate,
@@ -290,6 +292,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..9ebec0f 100644
--- a/devices/nidaqmx_device.py
+++ b/devices/nidaqmx_device.py
@@ -69,6 +69,7 @@ class NidaqmxDevice(BaseDevice):
channel_id=f"do{i}", name=f"DO {i}", unit="",
min_value=0.0, max_value=1.0,
color=_DO_COLORS[i % len(_DO_COLORS)],
+ writable=True,
))
info = DeviceInfo(
@@ -219,6 +220,7 @@ class NidaqmxDevice(BaseDevice):
return {
"device_type": self.DEVICE_TYPE,
"device_id": self.info.device_id,
+ "name": self.info.name,
"num_analog": self._num_analog,
"min_v": self._min_v,
"max_v": self._max_v,
@@ -294,6 +296,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 458aec8..fb131aa 100644
--- a/devices/serial_device.py
+++ b/devices/serial_device.py
@@ -142,7 +142,18 @@ class SerialDevice(BaseDevice):
raw = self._layer.read()
if not raw:
return {}
- # Protocol layers already use channel_id keys — pass through.
+ if self._fmt == "mark10" and hasattr(self._layer, "current_unit"):
+ # Gauge can be switched between lb/kgF/N/ozF on the device itself —
+ # keep the channel's unit in sync so new plots/pickers/CSV log
+ # headers pick up the currently-selected unit instead of a fixed
+ # default. Does not relabel the axis of an already-open plot pane.
+ force_ch = self.get_channel("force")
+ if force_ch is not None:
+ force_ch.unit = self._layer.current_unit
+ if self._fmt not in _GENERIC_FORMATS:
+ # Protocol layers already key by channel_id — pass through untouched.
+ # (Write-only channels, e.g. CML "M1_VS", never appear in raw — correctly dropped.)
+ return raw
# Generic (ArduinoLayer) may use arbitrary names — remap by position.
mapped: Dict[str, float] = {}
raw_vals = list(raw.values())
@@ -154,6 +165,8 @@ class SerialDevice(BaseDevice):
return mapped
def write_channel(self, channel_id: str, value: Any) -> bool:
+ if self.status not in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED):
+ return False
return self._layer.write(channel_id, int(value))
def get_config_widget(self) -> QWidget:
@@ -163,6 +176,7 @@ class SerialDevice(BaseDevice):
cfg: Dict[str, Any] = {
"device_type": self.DEVICE_TYPE,
"device_id": self.info.device_id,
+ "name": self.info.name,
"port": self._port,
"baud_rate": self._baud,
"parse_format": self._fmt,
@@ -259,6 +273,7 @@ class SerialDevice(BaseDevice):
unit=sc.get("unit", ""),
min_value=-1e9, max_value=1e9,
color=_COLORS[i % len(_COLORS)],
+ writable=bool(sc.get("write_cmd")),
)
for i, sc in enumerate(self._scpi_channels)
]
@@ -270,22 +285,28 @@ class SerialDevice(BaseDevice):
unit=mc.get("unit", ""),
min_value=-1e9, max_value=1e9,
color=_COLORS[i % len(_COLORS)],
+ # Holding registers (FC03) accept writes (FC06); input registers (FC04) don't.
+ writable=(mc.get("function_code", 0x03) == 0x03),
)
for i, mc in enumerate(self._mb_channels)
]
elif fmt == "mark10":
from api_layers.protocols.mark10 import UNITS as _MARK10_UNITS
return [
- ChannelConfig("force", "Force", "N", -5000.0, 5000.0, color=_COLORS[0]),
- ChannelConfig("unit_code", "Unit Code", "", 0.0, float(len(_MARK10_UNITS) - 1), color=_COLORS[1]),
+ ChannelConfig("force", "Force", "N", -5000.0, 5000.0, color=_COLORS[0]),
+ ChannelConfig("unit_code", "Unit Code", "", 0.0, float(len(_MARK10_UNITS) - 1), color=_COLORS[1]),
+ # Write-only action channels (no reading — for control buttons)
+ ChannelConfig("zero", "Zero Gauge", "", 0.0, 1.0, color=_COLORS[2], writable=True),
+ ChannelConfig("cycle_units", "Cycle Units", "", 0.0, 1.0, color=_COLORS[3], writable=True),
]
elif fmt == "cml":
_CMD_UNITS = {"TP": "counts", "TV": "counts/s", "TC": "%×10", "TS": "flags"}
+ _WRITE_UNITS = {"VS": "counts/s", "MA": "counts"}
channels = []
color_idx = 0
for motor in self._motors:
+ mid = motor.get("motor_id", "M1")
for cmd in motor.get("read_cmds", ["TP", "TV", "TC"]):
- mid = motor.get("motor_id", "M1")
channels.append(ChannelConfig(
channel_id=f"{mid}_{cmd}",
name=f"{mid} {cmd}",
@@ -294,6 +315,28 @@ class SerialDevice(BaseDevice):
color=_COLORS[color_idx % len(_COLORS)],
))
color_idx += 1
+ # Write-only setpoint channels (no reading — for control widgets)
+ for cmd in ("VS", "MA"):
+ channels.append(ChannelConfig(
+ channel_id=f"{mid}_{cmd}",
+ name=f"{mid} {cmd}",
+ unit=_WRITE_UNITS.get(cmd, ""),
+ min_value=-1e6, max_value=1e6,
+ color=_COLORS[color_idx % len(_COLORS)],
+ writable=True,
+ ))
+ color_idx += 1
+ # Write-only action channels — motor must be enabled (ME) before VS/MA take effect
+ for cmd, label in (("ME", "Enable"), ("MD", "Disable"), ("ST", "Stop")):
+ channels.append(ChannelConfig(
+ channel_id=f"{mid}_{cmd}",
+ name=f"{mid} {label}",
+ unit="",
+ min_value=0.0, max_value=1.0,
+ color=_COLORS[color_idx % len(_COLORS)],
+ writable=True,
+ ))
+ color_idx += 1
return channels or [ChannelConfig("M1_TP", "M1 TP", "counts", color=_COLORS[0])]
return []
@@ -419,7 +462,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)
@@ -793,7 +840,7 @@ class _CMLPanel(QWidget):
lay.setContentsMargins(0, 4, 0, 4)
lay.setSpacing(6)
- grp = QGroupBox("Motors (RS-232: addr=0 / RS-485: addr 1-31)")
+ grp = QGroupBox("Motors (motor ID 1-31, always sent explicitly)")
grp_lay = QVBoxLayout(grp)
scroll = QScrollArea()
@@ -856,9 +903,9 @@ class _MotorRow(QWidget):
lay.addWidget(self._id_edit)
self._addr_spin = QSpinBox()
- self._addr_spin.setRange(0, 31)
- self._addr_spin.setValue(m.get("address", 1))
- self._addr_spin.setToolTip("0 = RS-232 (no prefix), 1-31 = RS-485")
+ self._addr_spin.setRange(1, 31)
+ self._addr_spin.setValue(max(1, m.get("address", 1)))
+ self._addr_spin.setToolTip("CM1-C motor ID — always sent explicitly as \".<id>\"")
lay.addWidget(QLabel("Addr:"))
lay.addWidget(self._addr_spin)
diff --git a/docs/CM1-C_ASCII_Command_Cheatsheet.md b/docs/CM1-C_ASCII_Command_Cheatsheet.md
new file mode 100644
index 0000000..9fdd695
--- /dev/null
+++ b/docs/CM1-C_ASCII_Command_Cheatsheet.md
@@ -0,0 +1,189 @@
+# Cool Muscle CM1-C ASCII / CML Serial Control Cheat Sheet
+
+**Applies to:** CM1-C, with RT3.14-focused commands
+**Serial default:** 38400 baud, 8-N-1, no flow control
+**Terminator:** carriage return (`\r`, ASCII 13) after every command
+**Motor ID:** append `.1`, `.2`, etc. Always include it explicitly.
+
+> Use low speed and low torque during commissioning. Software commands are not a safety-rated E-stop.
+
+## Quick direct move
+
+```text
+M0.1=20
+A0.1=10
+S0.1=20
+P0.1=1000
+^.1
+```
+
+- `P0` target position in pulses
+- `S0` speed; actual unit is selected by `K37`
+- `A0` acceleration in thousands of pulses/s^2
+- `M0` torque limit, 0-100% of peak torque
+- `^` execute
+
+## Continuous rotation until stopped
+
+```text
+A0.1=10
+M0.1=30
+P0.1=1000000000
+S0.1=20
+^.1
+```
+
+Use negative speed for the opposite direction:
+
+```text
+S0.1=-20
+^.1
+```
+
+Stop:
+
+```text
+].1
+```
+
+`S0.1=0` also stops indefinite-position motion. Exact CW/CCW depends on `K45`.
+
+## Safety and enable commands
+
+| Command | Action |
+|---|---|
+| `].1` | Immediate normal software stop of motor 1; pauses a bank |
+| `*` | Emergency stop all motors on the chain |
+| `*1` | Clear emergency-stop state |
+| `).1` | Disable motor; shaft becomes free |
+| `(.1` | Enable motor |
+
+## Queries
+
+| Command | Information |
+|---|---|
+| `?.1` | Dynamic P0/A0/S0 |
+| `?85.1` | Firmware and motor ID |
+| `?90.1` | All K parameters |
+| `?91.1` / `?P.1` | Position registers |
+| `?92.1` / `?S.1` | Speed registers |
+| `?93.1` / `?A.1` | Acceleration registers |
+| `?95.1` | Position error |
+| `?96.1` | Current position |
+| `?97.1` | Current speed |
+| `?98.1` | Averaged current |
+| `?99.1` | Motor status |
+| `?70.1` | Input status |
+| `?71.1` | Temperature |
+| `?74.1` | Analog input |
+| `?1000.1` | All program and logic banks |
+| `K37.1` | Query one specific parameter |
+
+## Status values from `?99`
+
+- `0` moving
+- `1` position-error overflow
+- `2` overspeed/overvoltage
+- `4` overload/overcurrent
+- `8` in position / ready
+- `16` disabled
+- `32` push torque reached
+- `128` overtemperature
+- `256` push target reached before expected resistance
+- `512` emergency stop
+
+Values can be combined as a bit field.
+
+## Zeroing and homing
+
+| Command | Action |
+|---|---|
+| `|.1` | Run configured origin search |
+| `|1.1` | Move to position zero |
+| `|2.1` | Assign current position as zero |
+| `|4.1` | Soft reset |
+| `|11.1` | Clear whole-revolution count |
+
+The character is pipe `|` (ASCII 124), not capital I. Configure `K42`, `K43`, `K45`, `K46`, `K47`, and `K48` before origin search.
+
+## Outputs
+
+- `O1.1`, `O2.1`: output on
+- `F1.1`, `F2.1`: output off
+- `?51.1`, `?52.1`: output status
+- Configure output behavior in `K34`
+
+## Stored registers
+
+- `P1-P25`: positions
+- `S1-S15`: speeds
+- `A1-A8`: accelerations
+- `M1-M8`: torque limits
+- `T1-T8`: millisecond timers
+- `V1-V15`: variables/internal-state mappings
+- `N1-N25`, `R1-R25`: coordinated-motion or general-use data
+
+## Program banks
+
+```text
+B1
+S1,A1,P1
+END
+```
+
+- `[1.1`: execute program bank 1
+- `[L1.1`: execute logic bank 1
+- `].1`: pause immediately; send twice to terminate
+- `}.1`: stop after current motion
+- `>.1`, `<.1`: step through paused program
+- `]L.1`: stop logic bank
+- `B100`, `L100`: clear all banks
+- `$ .1` without the space: save to EEPROM; actual command is `$.1`
+
+## Important K parameters
+
+- `K20`: baud / ASCII versus Modbus
+- `K23`: serial event reporting and echo
+- `K37`: resolution and speed unit; default `K37=3` = 1000 ppr, 100 pps speed unit
+- `K44`: deceleration ratio
+- `K45`: direction and coordinate sign
+- `K55`: in-position tolerance
+- `K56`: position-error fault threshold
+- `K58`, `K59`: software position limits
+- `K70`: CR-only versus CR+LF replies
+
+K/H parameters auto-save by default. On suitable RT3.14 firmware, `_SKH=0` temporarily disables auto-saving to avoid repeated EEPROM writes; `_SKH=1` re-enables it.
+
+## PowerShell
+
+```powershell
+$PortName = "COM3"
+
+$cm = [System.IO.Ports.SerialPort]::new(
+ $PortName, 38400,
+ [System.IO.Ports.Parity]::None,
+ 8,
+ [System.IO.Ports.StopBits]::One
+)
+$cm.Handshake = [System.IO.Ports.Handshake]::None
+$cm.ReadTimeout = 700
+$cm.WriteTimeout = 700
+$cm.NewLine = "`r"
+$cm.Open()
+
+function Send-CM1 {
+ param(
+ [Parameter(Mandatory)][string]$Command,
+ [int]$WaitMs = 150
+ )
+ if (-not $script:cm -or -not $script:cm.IsOpen) {
+ throw "CM1 serial port is not open."
+ }
+ $null = $script:cm.ReadExisting()
+ $script:cm.Write($Command + "`r")
+ Start-Sleep -Milliseconds $WaitMs
+ $script:cm.ReadExisting()
+}
+```
+
+**Source basis:** Myostat CM1-C User Guide v3.00 (2025-12-05) and CM1 RT3.14 Quick Reference Guide v3.14.00.
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/plugins/motion_capture/device.py b/plugins/motion_capture/device.py
index 58f89ce..3369962 100644
--- a/plugins/motion_capture/device.py
+++ b/plugins/motion_capture/device.py
@@ -102,6 +102,7 @@ class CameraDevice(BaseDevice):
return {
"device_type": self.DEVICE_TYPE,
"device_id": self.info.device_id,
+ "name": self.info.name,
"camera_index": self._camera_index,
"simulate": self._simulate,
"resolution": list(self._resolution) if self._resolution else None,
diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py
index 36ae71a..5cefc2e 100644
--- a/plugins/plugin_manager.py
+++ b/plugins/plugin_manager.py
@@ -10,12 +10,14 @@ per-plugin state separately via get_save_state / apply_save_state.
from __future__ import annotations
+import importlib.metadata
import importlib.util
import json
import os
+import re
import sys
import traceback
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Dict, List, Optional
from plugins.base_plugin import LabPlugin, PluginContext
@@ -35,9 +37,35 @@ class PluginManifest:
description: str = ""
author: str = ""
entry_point: str = "plugin.Plugin" # "module.ClassName" relative to plugin dir
+ requires: List[str] = field(default_factory=list) # pip-style reqs, e.g. "opencv-python>=4.8.0"
plugin_dir: str = ""
+def _dist_name(requirement: str) -> str:
+ """Extract the distribution name from a requirement string, e.g.
+ "opencv-python>=4.8.0" -> "opencv-python"."""
+ return re.split(r"[<>=!~\[; ]", requirement.strip(), maxsplit=1)[0]
+
+
+def missing_requirements(requires: List[str]) -> List[str]:
+ """Return the subset of `requires` whose distribution isn't installed.
+
+ Checked by distribution name via importlib.metadata (matches what pip
+ installed it as), not by import name — those differ for packages like
+ opencv-python (imports as cv2) or pyserial (imports as serial).
+ """
+ missing = []
+ for req in requires:
+ name = _dist_name(req)
+ if not name:
+ continue
+ try:
+ importlib.metadata.version(name)
+ except importlib.metadata.PackageNotFoundError:
+ missing.append(req)
+ return missing
+
+
# ── Manager ───────────────────────────────────────────────────────────────────
class PluginManager:
@@ -94,6 +122,7 @@ class PluginManager:
description = data.get("description", ""),
author = data.get("author", ""),
entry_point = data.get("entry_point", "plugin.Plugin"),
+ requires = data.get("requires", []),
plugin_dir = plugin_dir,
)
self._manifests[m.plugin_id] = m
@@ -146,6 +175,11 @@ class PluginManager:
print(f"[PluginManager] No manifest for '{plugin_id}'")
return None
+ missing = missing_requirements(manifest.requires)
+ if missing:
+ print(f"[Plugin] '{plugin_id}' missing dependencies: {', '.join(missing)}")
+ return None
+
module_name, class_name = manifest.entry_point.rsplit(".", 1)
module_file = os.path.join(
manifest.plugin_dir, *module_name.split("/")
@@ -218,6 +252,10 @@ class PluginManager:
def get_manifests(self) -> List[PluginManifest]:
return list(self._manifests.values())
+ def get_missing_dependencies(self, plugin_id: str) -> List[str]:
+ manifest = self._manifests.get(plugin_id)
+ return missing_requirements(manifest.requires) if manifest else []
+
def get_loaded(self) -> List[LabPlugin]:
return list(self._loaded.values())
diff --git a/requirements.txt b/requirements.txt
index 5178fa4..05152ea 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,11 +3,10 @@ pyqtgraph>=0.13.3
numpy>=1.24.0
pyserial>=3.5
-# Optional - install if using real hardware:
+# Optional - install if using National Instruments hardware:
# nidaqmx>=0.9.0 # NI-DAQmx Python API (requires NI-DAQmx runtime)
-# For Arduino: pyserial is sufficient (already listed above)
+# pip install nidaqmx
# Optional - install for Motion Capture plugin:
# opencv-python>=4.8.0 # Camera capture + CSRT point tracking
-# Arch Linux: sudo pacman -S python-opencv
-# Other: pip install opencv-python
+# pip install opencv-python
diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py
index 2956a96..2bf0233 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)
@@ -299,6 +301,10 @@ class AddDeviceDialog(QDialog):
self._id_edit.setPlaceholderText("Leave blank for auto")
cfg_form.addRow("Device ID:", self._id_edit)
+ self._name_edit = QLineEdit()
+ self._name_edit.setPlaceholderText("Leave blank to use the default type name")
+ cfg_form.addRow("Display Name:", self._name_edit)
+
self._fmt_cb = QComboBox()
self._fmt_cb.addItems(list(_FORMAT_LABELS.keys()))
self._fmt_lbl = QLabel("Protocol / Format:")
@@ -449,6 +455,10 @@ class AddDeviceDialog(QDialog):
panel.set_simulate(sim)
dev = panel.build_device(dev_id)
+ display_name = self._name_edit.text().strip()
+ if display_name:
+ dev.info.name = display_name
+
self.created_device = dev
self.accept()
except Exception as e:
diff --git a/ui/config_dialog.py b/ui/config_dialog.py
index d9bc9df..5a95821 100644
--- a/ui/config_dialog.py
+++ b/ui/config_dialog.py
@@ -59,7 +59,14 @@ class DeviceConfigDialog(QDialog):
e = QLineEdit(str(v)); e.setReadOnly(True); return e
form.addRow("Device ID:", _ro(info.device_id))
- form.addRow("Name:", _ro(info.name))
+
+ def _on_name_edited():
+ info.name = name_edit.text().strip() or info.name
+ self.setWindowTitle(f"Configure — {info.name} [{info.device_id}]")
+
+ name_edit = QLineEdit(info.name)
+ name_edit.editingFinished.connect(_on_name_edited)
+ form.addRow("Name:", name_edit)
form.addRow("Type:", _ro(info.device_type))
form.addRow("Description:", _ro(info.description))
form.addRow("Manufacturer:", _ro(info.manufacturer))
diff --git a/ui/control_editor.py b/ui/control_editor.py
index 48de21d..111a71f 100644
--- a/ui/control_editor.py
+++ b/ui/control_editor.py
@@ -360,9 +360,13 @@ class ControlEditorDialog(QDialog):
dev = self.registry.get_instance(dev_id)
if not dev:
return
- # Add actual channels
+ # Add actual channels (skip disabled — can't be driven while switched
+ # off — and skip read-only channels — a control writes, so a channel
+ # with no write mapping should never be offered as a target)
for ch in dev.info.channels:
- self._ch_cb.addItem(f"{ch.channel_id} ({ch.name})",
+ if not ch.enabled or not ch.writable:
+ continue
+ self._ch_cb.addItem(f"{ch.name} ({ch.channel_id})",
userData=ch.channel_id)
# For Arduino backends also suggest digital pins for output
if hasattr(dev, "backend") and dev.backend == "arduino":
diff --git a/ui/control_panel.py b/ui/control_panel.py
index 61e1807..aef0f89 100644
--- a/ui/control_panel.py
+++ b/ui/control_panel.py
@@ -33,6 +33,7 @@ from PyQt6.QtCore import Qt, pyqtSignal, QTimer
from PyQt6.QtGui import QFont
from devices.device_registry import DeviceRegistry
+from devices.base_device import DeviceStatus
# ══════════════════════════════════════════════════════════════════════════════
@@ -119,13 +120,21 @@ class ControlWidget(QFrame):
if self.registry and self.device_id and self.channel_id:
dev = self.registry.get_instance(self.device_id)
if dev:
- ok = dev.write_channel(self.channel_id, value)
- if ok:
- written = True
+ ch = dev.get_channel(self.channel_id)
+ if ch is not None and not ch.enabled:
+ print(f"[Control] write_channel({self.channel_id}, {value}) skipped on "
+ f"{self.device_id} — channel is disabled")
+ elif dev.status not in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED):
+ print(f"[Control] write_channel({self.channel_id}, {value}) skipped on "
+ f"{self.device_id} — device status is {dev.status.value}, not connected")
else:
- print(f"[Control] write_channel({self.channel_id}, {value}) "
- f"returned False on {self.device_id} — "
- f"check device type and channel ID")
+ ok = dev.write_channel(self.channel_id, value)
+ if ok:
+ written = True
+ else:
+ print(f"[Control] write_channel({self.channel_id}, {value}) "
+ f"returned False on {self.device_id} — "
+ f"check device type and channel ID")
self.value_changed.emit(self.channel_id, value)
if self._on_action_fn is not None:
@@ -149,6 +158,21 @@ class ControlWidget(QFrame):
except Exception as e:
print(f"[Control '{self.title}'] script error: {e}")
+ def safe_stop(self):
+ """
+ Called on every control when the master Stop is pressed.
+
+ Default: zero the output. Widgets with a latched running/enabled
+ state (OnOffSwitch, MotorControl, PwmControl) override this to go
+ through their own toggle handler, so UI state and the write stay
+ consistent. Widgets that only write on an explicit user action
+ (SetpointControl, AnalogOutputControl) override with a no-op —
+ there's no universally "safe" value to force onto an arbitrary
+ process setpoint or analog output, so Stop leaves them alone
+ rather than guessing.
+ """
+ self._write(0.0)
+
# ══════════════════════════════════════════════════════════════════════════════
# On/Off Switch
@@ -202,6 +226,9 @@ class OnOffSwitch(ControlWidget):
w.style().unpolish(w); w.style().polish(w)
self._write(self._logic_level(checked))
+ def safe_stop(self):
+ self._btn.setChecked(False) # routes through _on_toggle: updates UI + writes off
+
# ══════════════════════════════════════════════════════════════════════════════
# Motor Control
@@ -289,6 +316,10 @@ class MotorControl(ControlWidget):
else:
self._on_speed(self._slider.value())
+ def safe_stop(self):
+ self._run_btn.setChecked(False) # routes through _on_run: stops + writes 0
+ self._slider.setValue(0)
+
# ══════════════════════════════════════════════════════════════════════════════
# Setpoint Control
@@ -381,6 +412,9 @@ class SetpointControl(ControlWidget):
def _decrement(self):
self._sp_spin.setValue(self._sp_spin.value() - self.step)
+ def safe_stop(self):
+ pass # no safe universal value for an arbitrary process setpoint — leave it
+
# ══════════════════════════════════════════════════════════════════════════════
# PWM Control
@@ -450,6 +484,10 @@ class PwmControl(ControlWidget):
self._en_btn.style().polish(self._en_btn)
self._write(float(self._dc_slider.value()) if en else 0.0)
+ def safe_stop(self):
+ self._en_btn.setChecked(False) # routes through _on_enable: disables + writes 0
+ self._dc_slider.setValue(0)
+
# ══════════════════════════════════════════════════════════════════════════════
# Generic Analog Output
@@ -504,6 +542,9 @@ class AnalogOutputControl(ControlWidget):
self._slider.setValue(max(0, min(1000, norm)))
self._slider.blockSignals(False)
+ def safe_stop(self):
+ pass # only writes on explicit SET click — no safe universal value to force
+
# ══════════════════════════════════════════════════════════════════════════════
# Control Panel container
@@ -548,6 +589,14 @@ class ControlPanel(QWidget):
# ── Widget management ─────────────────────────────────────────────────────
+ def safe_stop_all(self):
+ """Master Stop — tell every control widget to go to a safe state."""
+ for w in self._widgets:
+ try:
+ w.safe_stop()
+ except Exception as e:
+ print(f"[Control '{w.title}'] safe_stop failed: {e}")
+
def _make_wrapper(self, widget: ControlWidget, spec) -> QFrame:
"""Wrap a ControlWidget with Edit / Remove / reorder buttons."""
wrapper = QFrame(); wrapper.setObjectName("controlWidgetWrapper")
diff --git a/ui/main_window.py b/ui/main_window.py
index 93fbef2..01e0505 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -108,10 +108,11 @@ from ui.profile_manager_ui import ProfileButton
from ui.windows.config_window import ConfigWindow
from ui.windows.plot_window import build_default_layout
from ui.windows.settings_window import SettingsWindow
+from ui.windows.debug_window import DebugWindow
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")
@@ -132,6 +133,7 @@ class MainWindow(QMainWindow):
self._elapsed = 0
self._win_config = 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")
@@ -252,6 +254,12 @@ class MainWindow(QMainWindow):
set_btn.clicked.connect(lambda c: self._open_settings())
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._open_debug())
+ tb.addWidget(debug_btn); self._btn_debug = debug_btn
+ debug_btn.setVisible(False) # shown/hidden by _update_debug_btn_visibility per developer-mode setting
+
# ── Central ───────────────────────────────────────────────────────
central = QWidget(); self.setCentralWidget(central)
@@ -294,6 +302,9 @@ class MainWindow(QMainWindow):
self._clock = QTimer(self); self._clock.setInterval(1000)
self._clock.timeout.connect(self._tick)
+ self._rec_blink = QTimer(self); self._rec_blink.setInterval(600)
+ self._rec_blink.timeout.connect(self._tick_rec_blink)
+
def _on_ctrl_dock_visibility(self, visible: bool):
self._ctrl_tab.setVisible(not visible)
if hasattr(self, "_act_ctrl_panel"):
@@ -409,11 +420,60 @@ class MainWindow(QMainWindow):
def plugin_enable(self, plugin_id: str):
"""Called by SettingsWindow when user enables a plugin."""
+ missing = self._plugin_mgr.get_missing_dependencies(plugin_id)
+ if missing:
+ from PyQt6.QtWidgets import QMessageBox
+ reply = QMessageBox.question(
+ self, "Missing Plugin Dependencies",
+ f"This plugin needs packages that aren't installed:\n\n"
+ f" {', '.join(missing)}\n\n"
+ f"Install them now with pip?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ if self._pip_install(missing):
+ self._enable_plugin_now(plugin_id)
+ else:
+ self._enable_plugin_now(plugin_id)
+ if self._win_settings:
+ self._win_settings.sync_plugin_button(plugin_id)
+
+ def _enable_plugin_now(self, plugin_id: str):
ctx = self._make_plugin_context()
plugin = self._plugin_mgr.enable(plugin_id, ctx)
if plugin:
self._install_plugin(plugin)
+ def _pip_install(self, requirements: list) -> bool:
+ """Blocking `pip install` of the given requirement strings.
+ Returns True on success; shows a result dialog either way."""
+ import subprocess
+ import sys
+ from PyQt6.QtWidgets import QMessageBox
+
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", *requirements],
+ capture_output=True, text=True,
+ )
+ finally:
+ QApplication.restoreOverrideCursor()
+
+ if result.returncode == 0:
+ QMessageBox.information(
+ self, "Install Complete",
+ f"Installed: {', '.join(requirements)}"
+ )
+ return True
+ QMessageBox.critical(
+ self, "Install Failed",
+ f"pip install failed for: {', '.join(requirements)}\n\n"
+ f"{result.stderr.strip()[-1500:]}"
+ )
+ return False
+
def plugin_disable(self, plugin_id: str):
"""Called by SettingsWindow when user disables a plugin."""
self._uninstall_plugin(plugin_id)
@@ -440,6 +500,12 @@ class MainWindow(QMainWindow):
self._win_config.tabs.setCurrentIndex(tab)
self._show_win(self._win_config, "right")
+ def _open_debug(self):
+ 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 _on_config_closed(self):
self._act_devices.setChecked(False)
self._act_channels.setChecked(False)
@@ -603,9 +669,12 @@ class MainWindow(QMainWindow):
self._run_btn.setText("⏹ STOP"); self._log_btn.setEnabled(True)
self._clock.start(); self._status.setText("Acquiring…")
else:
+ self._ctrl.safe_stop_all() # master switch — stop outputs before halting acquisition
self.engine.stop()
self._run_btn.setText("▶ RUN")
- if self._log_btn.isChecked(): self._log_btn.setChecked(False)
+ if self._log_btn.isChecked():
+ self._log_btn.setChecked(False)
+ self._toggle_log(False) # setChecked() alone won't fire clicked — stop blink/logging explicitly
self._log_btn.setEnabled(False); self._clock.stop()
self._status.setText("Stopped")
@@ -621,8 +690,18 @@ class MainWindow(QMainWindow):
os.path.join(self._settings.get("log_dir", "logs"), ""))
self._log_btn.setText("⏹ LOGGING")
self._status.setText(f"Logging → {p}")
+ self._rec_blink.start()
else:
self.engine.stop_logging(); self._log_btn.setText("⬤ LOG")
+ self._rec_blink.stop()
+ self._log_btn.setProperty("recording", False)
+ self._log_btn.style().unpolish(self._log_btn); self._log_btn.style().polish(self._log_btn)
+
+ def _tick_rec_blink(self):
+ """Pulse the Log button's background while a recording is active."""
+ on = not self._log_btn.property("recording")
+ self._log_btn.setProperty("recording", on)
+ self._log_btn.style().unpolish(self._log_btn); self._log_btn.style().polish(self._log_btn)
# ── Theme / settings ──────────────────────────────────────────────────
@@ -641,6 +720,17 @@ class MainWindow(QMainWindow):
def _apply_developer_mode(self, enabled: bool):
level = logging.DEBUG if enabled else logging.WARNING
logging.getLogger().setLevel(level)
+ set_developer_mode(enabled)
+ self._update_debug_btn_visibility()
+
+ 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 _on_settings(self, cfg: dict):
self._settings.update(cfg)
@@ -654,7 +744,7 @@ class MainWindow(QMainWindow):
self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}")
def closeEvent(self, event):
- for w in (self._win_config, self._win_settings):
+ for w in (self._win_config, self._win_settings, self._win_debug):
if w: w.close()
for plugin in list(self._plugin_mgr.get_loaded()):
try:
diff --git a/ui/plot_builder.py b/ui/plot_builder.py
index ad6d231..7445bda 100644
--- a/ui/plot_builder.py
+++ b/ui/plot_builder.py
@@ -298,10 +298,14 @@ class PlotBlock(QFrame):
cb.setPlaceholderText("Select channel…")
for dev in self.registry.all_instances():
for ch in dev.info.channels:
- cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})",
+ if not ch.enabled:
+ continue
+ cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})",
userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color))
for dc in self.processor.get_derived():
- cb.addItem(f"[derived] {dc.channel_id} ({dc.name})",
+ if not dc.enabled:
+ continue
+ cb.addItem(f"{dc.name} ([derived]/{dc.channel_id})",
userData=("derived", dc.channel_id, dc.name, dc.color))
return cb
diff --git a/ui/plot_config.py b/ui/plot_config.py
index 058915f..c6ecaa4 100644
--- a/ui/plot_config.py
+++ b/ui/plot_config.py
@@ -333,7 +333,9 @@ class PlotBlock(QFrame):
cb.setPlaceholderText("Select channel…")
for dev in self.registry.all_instances():
for ch in dev.info.channels:
- label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})"
+ if not ch.enabled:
+ continue
+ label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})"
cb.addItem(label, userData=(dev.info.device_id, ch.channel_id,
ch.name, ch.color))
return cb
diff --git a/ui/style_dark.qss b/ui/style_dark.qss
index 029d085..15af970 100644
--- a/ui/style_dark.qss
+++ b/ui/style_dark.qss
@@ -63,7 +63,9 @@ QPushButton#logButton {
min-width: 80px;
}
QPushButton#logButton:enabled { color: #e2e8f0; border-color: #3b82f6; }
+QPushButton#logButton:disabled { background-color: #12172a; color: #3d4a6b; border: 1px solid #1e2740; }
QPushButton#logButton:checked { background-color: #7c2d12; border-color: #ef4444; color: #fee2e2; }
+QPushButton#logButton[recording="true"] { background-color: #ef4444; border-color: #fca5a5; color: #ffffff; }
QPushButton#addDeviceButton {
background-color: #1e3a5f;
diff --git a/ui/style_light.qss b/ui/style_light.qss
index 72619c6..a7b3cb6 100644
--- a/ui/style_light.qss
+++ b/ui/style_light.qss
@@ -6,7 +6,9 @@ QPushButton#runButton { background:#166534; color:#dcfce7; border:1px solid #22c
QPushButton#runButton:checked { background:#991b1b; border-color:#ef4444; color:#fee2e2; }
QPushButton#logButton { background:#f1f5f9; color:#64748b; border:1px solid #cbd5e1; border-radius:4px; padding:5px 14px; font-family:"IBM Plex Mono",monospace; min-width:80px; }
QPushButton#logButton:enabled { color:#1e293b; border-color:#3b82f6; }
+QPushButton#logButton:disabled { background:#f8fafc; color:#94a3b8; border:1px solid #e2e8f0; }
QPushButton#logButton:checked { background:#fef2f2; border-color:#ef4444; color:#991b1b; }
+QPushButton#logButton[recording="true"] { background:#ef4444; border-color:#fca5a5; color:#ffffff; }
QPushButton#toolbarSectionBtn { background:#f1f5f9; color:#475569; border:1px solid #cbd5e1; border-radius:4px; padding:5px 14px; font-weight:600; }
QPushButton#toolbarSectionBtn:hover { background:#e2e8f0; color:#1e293b; }
QPushButton#toolbarSectionBtn:checked { background:#dbeafe; color:#1d4ed8; border-color:#3b82f6; }
diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py
index 381800a..38d477b 100644
--- a/ui/windows/channels_window.py
+++ b/ui/windows/channels_window.py
@@ -74,7 +74,7 @@ def _channel_combo(registry: DeviceRegistry,
for ch in dev.info.channels:
if not ch.enabled:
continue
- label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})"
+ label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})"
if show_unit and ch.unit:
label += f" [{ch.unit}]"
cb.addItem(label, userData=(dev.info.device_id, ch.channel_id))
@@ -115,7 +115,7 @@ class ChannelPickerDialog(QDialog):
if (dev.info.device_id, ch.channel_id) in already_shown:
continue
any_available = True
- label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})"
+ label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})"
if ch.unit:
label += f" [{ch.unit}]"
chk = QCheckBox(label)
@@ -236,7 +236,7 @@ class ChannelPipelineBlock(QFrame):
# Header
hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0)
- title = f"{self.dev_id} / {self.ch_id} ({ch_name})"
+ title = f"{ch_name} ({self.dev_id}/{self.ch_id})"
if unit:
title += f" [{unit}]"
self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource")
@@ -307,7 +307,7 @@ class ChannelPipelineBlock(QFrame):
self._body.setVisible(False)
def _refresh_title(self):
- title = f"{self.dev_id} / {self.ch_id} ({self._ch_name})"
+ title = f"{self._ch_name} ({self.dev_id}/{self.ch_id})"
if self._unit:
title += f" [{self._unit}]"
self._title_lbl.setText(title)
@@ -444,7 +444,12 @@ class PipelineTab(QWidget):
virt_bar = QWidget(); virt_bar.setObjectName("cfgGlobalBar")
vb_lay = QHBoxLayout(virt_bar); vb_lay.setContentsMargins(10, 7, 10, 7); vb_lay.setSpacing(6)
vb_lbl = QLabel("Channels"); vb_lbl.setObjectName("devWindowTitle")
- vb_lay.addWidget(vb_lbl, 1)
+ vb_lay.addWidget(vb_lbl)
+ self._src_cb = _channel_combo(self.registry, self.processor, include_derived=True)
+ self._src_cb.setObjectName("channelPickerCb")
+ self._src_cb.insertItem(0, "Source: none (empty channel)", userData=None)
+ self._src_cb.setCurrentIndex(0)
+ vb_lay.addWidget(self._src_cb, 1)
add_virt = QPushButton("+ Add Channel"); add_virt.setObjectName("addTraceBtn")
add_virt.clicked.connect(self._add_virtual)
vb_lay.addWidget(add_virt)
@@ -579,6 +584,11 @@ class PipelineTab(QWidget):
kind="expression", color=color)
blk = self._make_derived_block(dc)
self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
+ # Pre-seed the source picked in the bar above, if any — otherwise the
+ # channel is created empty and sources can be added manually.
+ src = self._src_cb.currentData()
+ if src:
+ blk._add_src(src)
def _make_derived_block(self, dc: DerivedChannel) -> DerivedBlock:
blk = DerivedBlock(dc, self.registry, self.processor)
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/devices_window.py b/ui/windows/devices_window.py
index ac9d13c..eec65c8 100644
--- a/ui/windows/devices_window.py
+++ b/ui/windows/devices_window.py
@@ -124,12 +124,11 @@ class ChannelsTab(QWidget):
# Col indices
_C_DEVICE = 0
- _C_CH_ID = 1
- _C_ENABLED = 2
- _C_NAME = 3
- _C_UNIT = 4
- _C_MIN = 5
- _C_MAX = 6
+ _C_ENABLED = 1
+ _C_NAME = 2
+ _C_UNIT = 3
+ _C_MIN = 4
+ _C_MAX = 5
def __init__(self, registry: DeviceRegistry):
super().__init__()
@@ -142,14 +141,13 @@ class ChannelsTab(QWidget):
self._table = QTableWidget()
self._table.setObjectName("channelTable")
- self._table.setColumnCount(7)
+ self._table.setColumnCount(6)
self._table.setHorizontalHeaderLabels(
- ["Device", "Signal ID", "On", "Name", "Unit", "Min", "Max"]
+ ["Device", "On", "Name", "Unit", "Min", "Max"]
)
hdr = self._table.horizontalHeader()
hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents)
- hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setAlternatingRowColors(True)
@@ -165,16 +163,13 @@ class ChannelsTab(QWidget):
for ch in dev.info.channels:
self._table.insertRow(row)
- dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}")
+ # Device + signal ID folded into one non-editable column —
+ # the separate "Signal ID" column was removed as redundant.
+ dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id} / {ch.channel_id}")
dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
dev_item.setForeground(QColor("#64748b"))
self._table.setItem(row, self._C_DEVICE, dev_item)
- ch_item = QTableWidgetItem(ch.channel_id)
- ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
- ch_item.setForeground(QColor(ch.color))
- self._table.setItem(row, self._C_CH_ID, ch_item)
-
# Enabled checkbox — centred in cell
chk_container = QWidget()
chk_lay = QHBoxLayout(chk_container)
@@ -349,7 +344,7 @@ class DevicesWindow(QWidget):
dev = self.registry.get_instance(device_id)
if dev:
DeviceConfigDialog(dev, self).exec()
- self._ch_tab.refresh()
+ self.refresh() # rebuilds device rows (picks up a renamed display name) + Signals tab
self.device_reconfigured.emit(device_id)
def _on_remove(self, device_id: str):
diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py
index 9edf046..f311e9e 100644
--- a/ui/windows/plot_window.py
+++ b/ui/windows/plot_window.py
@@ -355,10 +355,14 @@ class PaneBlock(QFrame):
self._x_cb.addItem("⏱ Time (elapsed s)", userData="time")
for dev in self.registry.all_instances():
for ch in dev.info.channels:
- self._x_cb.addItem(f"{dev.info.device_id}/{ch.channel_id} ({ch.name})",
+ if not ch.enabled:
+ continue
+ self._x_cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})",
userData=f"{dev.info.device_id}/{ch.channel_id}")
for dc in self.processor.get_derived():
- self._x_cb.addItem(f"[virtual] {dc.channel_id}",
+ if not dc.enabled:
+ continue
+ self._x_cb.addItem(f"{dc.name} ([virtual]/{dc.channel_id})",
userData=f"derived/{dc.channel_id}")
for i in range(self._x_cb.count()):
if self._x_cb.itemData(i) == self.spec.x_source:
@@ -417,10 +421,14 @@ class PaneBlock(QFrame):
cb = QComboBox(); cb.setObjectName("channelPickerCb")
for dev in self.registry.all_instances():
for ch in dev.info.channels:
- cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})",
+ if not ch.enabled:
+ continue
+ cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})",
userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color))
for dc in self.processor.get_derived():
- cb.addItem(f"[virtual] {dc.channel_id} ({dc.name})",
+ if not dc.enabled:
+ continue
+ cb.addItem(f"{dc.name} ([virtual]/{dc.channel_id})",
userData=("derived", dc.channel_id, dc.name, dc.color))
return cb
diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py
index 1afbca5..09b7a1a 100644
--- a/ui/windows/settings_window.py
+++ b/ui/windows/settings_window.py
@@ -51,6 +51,7 @@ class SettingsWindow(QWidget):
self.registry = registry
self.engine = engine
self._plugin_mgr = plugin_manager
+ self._plugin_buttons: dict = {} # plugin_id -> QPushButton
self.cfg = dict(self._defaults)
if current:
self.cfg.update(current)
@@ -78,7 +79,6 @@ class SettingsWindow(QWidget):
tabs.addTab(self._acquisition_tab(), " Acquisition ")
tabs.addTab(self._display_tab(), " Display ")
tabs.addTab(self._plugins_tab(), " Plugins ")
- tabs.addTab(self._advanced_tab(), " Advanced ")
# Bottom bar
btm = QWidget(); btm.setObjectName("cfgBottomBar")
@@ -110,6 +110,15 @@ 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, and enables verbose DEBUG output in\n"
+ "the terminal. 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)
@@ -122,13 +131,13 @@ 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"])
self._tw_sp.setValue(self.cfg["time_window_s"])
self._legend_chk.setChecked(self.cfg["show_legend"])
self._grid_chk.setChecked(self.cfg["show_grid"])
- self._dev_mode_chk.setChecked(self.cfg.get("developer_mode", False))
def _acquisition_tab(self):
w = QWidget()
@@ -236,6 +245,7 @@ class SettingsWindow(QWidget):
toggle.clicked.connect(
lambda _, pid=manifest.plugin_id, btn=toggle: self._toggle_plugin(pid, btn)
)
+ self._plugin_buttons[manifest.plugin_id] = toggle
hdr.addWidget(toggle)
cl.addLayout(hdr)
@@ -259,32 +269,21 @@ class SettingsWindow(QWidget):
return card
- def _advanced_tab(self):
- w = QWidget()
- scroll = QScrollArea(); scroll.setWidgetResizable(True)
- scroll.setObjectName("deviceScroll")
- cont = QWidget(); lay = QFormLayout(cont)
- lay.setContentsMargins(16, 14, 16, 14); lay.setSpacing(10)
-
- self._dev_mode_chk = QCheckBox()
- self._dev_mode_chk.setChecked(self.cfg.get("developer_mode", False))
- lay.addRow("Developer mode:", self._dev_mode_chk)
-
- note = QLabel("Enables verbose DEBUG output in the terminal.\nNo effect when running as a packaged app.")
- note.setObjectName("traceSource"); note.setWordWrap(True)
- lay.addRow("", note)
-
- scroll.setWidget(cont)
- root = QVBoxLayout(w); root.setContentsMargins(0, 0, 0, 0); root.addWidget(scroll)
- return w
-
def _toggle_plugin(self, plugin_id: str, btn: QPushButton):
if self._plugin_mgr.is_enabled(plugin_id):
self.plugin_disable_requested.emit(plugin_id)
btn.setText("Enable")
else:
+ # Don't flip to "Disable" yet — enabling can fail (missing
+ # dependencies, bad plugin code). main_window confirms the
+ # real outcome via sync_plugin_button() once enable() returns.
self.plugin_enable_requested.emit(plugin_id)
- btn.setText("Disable")
+
+ def sync_plugin_button(self, plugin_id: str):
+ """Refresh one plugin's toggle button to match its actual enabled state."""
+ btn = self._plugin_buttons.get(plugin_id)
+ if btn is not None and self._plugin_mgr is not None:
+ btn.setText("Disable" if self._plugin_mgr.is_enabled(plugin_id) else "Enable")
# ── Actions ───────────────────────────────────────────────────────────
@@ -310,13 +309,13 @@ 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(),
"time_window_s": self._tw_sp.value(),
"show_legend": self._legend_chk.isChecked(),
"show_grid": self._grid_chk.isChecked(),
- "developer_mode": self._dev_mode_chk.isChecked(),
})
self.theme_changed.emit(theme)
self.settings_changed.emit(dict(self.cfg))