diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-08-02 01:34:43 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-08-02 01:34:43 -0600 |
| commit | a3aa1df99df8f413cac2ba6020b7cd0dec6d2390 (patch) | |
| tree | a4c5feca6b0db326d9e54f417abc132c1270a7a3 | |
| parent | f5066a8ca2fb50aa3dddf2c8847e52574cdde6ad (diff) | |
| parent | f1aaffbc3eb1e2c154315c556d2555803eea7997 (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>
34 files changed, 836 insertions, 138 deletions
@@ -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. @@ -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)) |
