From 23c2d8a1e32ad0d9d22f6f5ca2907e4b2ce9498e Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Tue, 14 Jul 2026 13:09:49 -0600 Subject: ignoring .venv directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0b59048..ac18dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ logs/* __pychache__/ *.pyc *.pyo +.venv/ \ No newline at end of file -- cgit v1.2.3 From 49294cdeb57b537308145e6ec552d05e61e69dfa Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 27 Jul 2026 16:47:47 -0600 Subject: Add simulation handling to protocol layers and update motion_capture setting --- .gitignore | 3 +++ api_layers/protocols/cml.py | 2 ++ api_layers/protocols/mark10.py | 6 +++++- api_layers/protocols/modbus_rtu.py | 2 ++ api_layers/protocols/scpi.py | 2 ++ 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0b59048..c2d1427 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ logs/* __pychache__/ *.pyc *.pyo +.claude/settings.local.json +plugins/enabled.json +plugins/enabled.json diff --git a/api_layers/protocols/cml.py b/api_layers/protocols/cml.py index dbee2fa..67be0d5 100644 --- a/api_layers/protocols/cml.py +++ b/api_layers/protocols/cml.py @@ -119,6 +119,8 @@ 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: diff --git a/api_layers/protocols/mark10.py b/api_layers/protocols/mark10.py index 1b5bf1c..4a97892 100644 --- a/api_layers/protocols/mark10.py +++ b/api_layers/protocols/mark10.py @@ -76,7 +76,11 @@ 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) diff --git a/api_layers/protocols/modbus_rtu.py b/api_layers/protocols/modbus_rtu.py index 9d371b1..68d644e 100644 --- a/api_layers/protocols/modbus_rtu.py +++ b/api_layers/protocols/modbus_rtu.py @@ -151,6 +151,8 @@ class ModbusRTULayer(BaseProtocol): 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..526ed21 100644 --- a/api_layers/protocols/scpi.py +++ b/api_layers/protocols/scpi.py @@ -73,6 +73,8 @@ 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()) -- cgit v1.2.3 From fc6f90404c9428362f1a0c62bb28eb350c0f248d Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 27 Jul 2026 16:56:50 -0600 Subject: Enhance SerialDevice: support write-only channels and improve raw data handling --- devices/serial_device.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/devices/serial_device.py b/devices/serial_device.py index 458aec8..8b8de04 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -142,7 +142,10 @@ class SerialDevice(BaseDevice): raw = self._layer.read() if not raw: return {} - # Protocol layers already use channel_id keys — pass through. + 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()) @@ -276,16 +279,20 @@ class SerialDevice(BaseDevice): 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]), + ChannelConfig("cycle_units", "Cycle Units", "", 0.0, 1.0, color=_COLORS[3]), ] 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 +301,16 @@ 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)], + )) + color_idx += 1 return channels or [ChannelConfig("M1_TP", "M1 TP", "counts", color=_COLORS[0])] return [] -- cgit v1.2.3 From c3f07a7ffa320aecb3fca5dd40e7644424bddfb5 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 27 Jul 2026 17:13:41 -0600 Subject: Fix .gitignore: remove duplicate entry for plugins/enabled.json --- .gitignore | 1 + devices/serial_device.py | 2 ++ ui/control_panel.py | 17 +++++++++++------ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index c2d1427..bcef6d0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pychache__/ .claude/settings.local.json plugins/enabled.json plugins/enabled.json +plugins/enabled.json diff --git a/devices/serial_device.py b/devices/serial_device.py index 8b8de04..ca551c6 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -157,6 +157,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: diff --git a/ui/control_panel.py b/ui/control_panel.py index 6e78e89..2b936c4 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,17 @@ 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 + if 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: -- cgit v1.2.3 From ee4965f4d4d1fc5d40be24b99341620e32386d40 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 27 Jul 2026 17:34:30 -0600 Subject: Enhance protocol layers: implement I/O locking for thread safety in serial communication --- .gitignore | 1 + api_layers/protocols/base_protocol.py | 1 + api_layers/protocols/cml.py | 12 ++++++++---- api_layers/protocols/mark10.py | 14 +++++++++----- api_layers/protocols/modbus_rtu.py | 16 +++++++++------- api_layers/protocols/scpi.py | 13 ++++++++----- devices/serial_device.py | 10 ++++++++++ 7 files changed, 46 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index bcef6d0..7a03d40 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pychache__/ plugins/enabled.json plugins/enabled.json plugins/enabled.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 67be0d5..6bc85f0 100644 --- a/api_layers/protocols/cml.py +++ b/api_layers/protocols/cml.py @@ -73,9 +73,10 @@ class CMLLayer(BaseProtocol): frame = f"{cmd}\r".encode() else: frame = f"#{motor.address}{cmd}\r".encode() - self._ser.write(frame) - self._ser.flush() - resp = self._ser.readline().decode(errors="replace").strip() + with self._io_lock: + self._ser.write(frame) + self._ser.flush() + resp = self._ser.readline().decode(errors="replace").strip() return _parse_cml_response(resp) def _poll(self) -> Dict[str, float]: @@ -127,7 +128,10 @@ class CMLLayer(BaseProtocol): frame = f"{cmd}{data}\r".encode() else: frame = f"#{motor.address}{cmd}{data}\r".encode() - self._ser.write(frame) + with self._io_lock: + self._ser.write(frame) + self._ser.flush() + self._ser.readline() # discard echo-back so it doesn't desync the next poll read return True except Exception: return False diff --git a/api_layers/protocols/mark10.py b/api_layers/protocols/mark10.py index 4a97892..b7dacb4 100644 --- a/api_layers/protocols/mark10.py +++ b/api_layers/protocols/mark10.py @@ -43,8 +43,9 @@ class Mark10Layer(BaseProtocol): 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.write(b"?\r") + resp = self._ser.readline().decode(errors="replace").strip() return self._parse(resp) except Exception: return {} @@ -83,7 +84,8 @@ class Mark10Layer(BaseProtocol): 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 @@ -92,8 +94,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 68d644e..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(" 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: diff --git a/api_layers/protocols/scpi.py b/api_layers/protocols/scpi.py index 526ed21..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 @@ -77,7 +78,8 @@ class SCPILayer(BaseProtocol): 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 @@ -87,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/devices/serial_device.py b/devices/serial_device.py index ca551c6..dc92b12 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -313,6 +313,16 @@ class SerialDevice(BaseDevice): color=_COLORS[color_idx % len(_COLORS)], )) 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")): + 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)], + )) + color_idx += 1 return channels or [ChannelConfig("M1_TP", "M1 TP", "counts", color=_COLORS[0])] return [] -- cgit v1.2.3 From 96ef35db552d26e6d962c9ff6f9da5c928c0ad76 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Tue, 28 Jul 2026 16:33:02 -0600 Subject: Added documentation for CM1 servo motors --- docs/CM1-C_ASCII_Command_Cheatsheet.md | 189 +++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/CM1-C_ASCII_Command_Cheatsheet.md 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. -- cgit v1.2.3 From b2dc94bb2e7c7e57cba202697ed4d58bbb1f01f5 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Tue, 28 Jul 2026 17:01:38 -0600 Subject: Update CML protocol documentation and improve command handling; adjust motion_capture setting to false --- api_layers/protocols/cml.py | 118 ++++++++++++++++++++++++++--------------- api_layers/protocols/mark10.py | 9 +++- devices/serial_device.py | 10 ++-- plugins/enabled.json | 2 +- 4 files changed, 88 insertions(+), 51 deletions(-) diff --git a/api_layers/protocols/cml.py b/api_layers/protocols/cml.py index 6bc85f0..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: + .[=]\\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: []\\r - Response: []\\r (echo-back) +Motor ID is always appended explicitly as "." on every command — there is +no address-less / prefix-free mode. - RS-485 (address 1-31): - Command: #[]\\r - Response: *[]\\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 — Move Absolute (encoder counts) - MR — Move Relative (encoder counts) - VS — Velocity Setpoint + ME — Motor Enable ("(") + MD — Motor Disable (")") + ST — Immediate stop ("]") + VS — 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 — Move absolute: sets P0 to then executes with "^". Channel IDs follow pattern: _ - 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 ".
" read_cmds: List[str] = field(default_factory=lambda: ["TP", "TV", "TC"]) @@ -66,17 +72,27 @@ class CMLLayer(BaseProtocol): super().__init__(port, baud, poll_interval, simulate) self.motors = motors or [CMLMotor("M1", address=1)] + # ── Low-level I/O ───────────────────────────────────────────────────── + + 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() + + 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]: - if motor.address == 0: - frame = f"{cmd}\r".encode() - else: - frame = f"#{motor.address}{cmd}\r".encode() + query = _QUERY_CMDS.get(cmd) + if query is None: + return None with self._io_lock: - self._ser.write(frame) - self._ser.flush() - resp = self._ser.readline().decode(errors="replace").strip() + self._send(motor.address, query) + resp = self._read_reply() return _parse_cml_response(resp) def _poll(self) -> Dict[str, float]: @@ -111,8 +127,7 @@ class CMLLayer(BaseProtocol): return result def write(self, channel_id: str, value) -> bool: - # channel_id: "_[]" - # e.g. "M1_ME", "M1_MA" (value carries position) + # channel_id: "_" e.g. "M1_ME", "M1_VS" (value carries speed) parts = channel_id.split("_", 1) if len(parts) != 2: return False @@ -123,23 +138,38 @@ class CMLLayer(BaseProtocol): 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() with self._io_lock: - self._ser.write(frame) - self._ser.flush() - self._ser.readline() # discard echo-back so it doesn't desync the next poll read + 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 b7dacb4..a209232 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) @@ -44,8 +49,10 @@ class Mark10Layer(BaseProtocol): def _poll(self) -> Dict[str, float]: try: with self._io_lock: + self._ser.reset_input_buffer() self._ser.write(b"?\r") - resp = self._ser.readline().decode(errors="replace").strip() + self._ser.flush() + resp = self._ser.read_until(b"\r").decode(errors="replace").strip() return self._parse(resp) except Exception: return {} diff --git a/devices/serial_device.py b/devices/serial_device.py index dc92b12..067656a 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -314,7 +314,7 @@ class SerialDevice(BaseDevice): )) 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")): + for cmd, label in (("ME", "Enable"), ("MD", "Disable"), ("ST", "Stop")): channels.append(ChannelConfig( channel_id=f"{mid}_{cmd}", name=f"{mid} {label}", @@ -822,7 +822,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() @@ -885,9 +885,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 \".\"") lay.addWidget(QLabel("Addr:")) lay.addWidget(self._addr_spin) diff --git a/plugins/enabled.json b/plugins/enabled.json index a33a40a..7d3abd6 100644 --- a/plugins/enabled.json +++ b/plugins/enabled.json @@ -1,3 +1,3 @@ { - "motion_capture": true + "motion_capture": false } \ No newline at end of file -- cgit v1.2.3 From 9360dea6e6327004b905eb6d7c782cc7c0d3ba13 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 29 Jul 2026 12:59:10 -0600 Subject: Hide non-writable channels from Controls picker, gate writes on enabled+writable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ChannelConfig.writable so the Controls editor can only bind to channels with a real write mapping (VS/MA/ME/MD/ST for CML, do*/DO for digital I/O, write_cmd-configured SCPI channels, holding-register Modbus channels) instead of read-only telemetry — this is exactly the class of mistake hit earlier (binding a motor-speed control to M1_TV instead of M1_VS). Also carries forward the enabled-channel gating for controls (hide disabled channels from the picker, block writes to them at _write() time) done alongside this investigation. Co-Authored-By: Claude Sonnet 5 --- devices/arduino_device.py | 1 + devices/base_device.py | 1 + devices/digital_io.py | 1 + devices/nidaqmx_device.py | 1 + devices/serial_device.py | 9 +++++++-- ui/control_editor.py | 6 +++++- ui/control_panel.py | 6 +++++- 7 files changed, 21 insertions(+), 4 deletions(-) diff --git a/devices/arduino_device.py b/devices/arduino_device.py index 7c1d21b..f2407e6 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)], )) diff --git a/devices/base_device.py b/devices/base_device.py index 32fe787..83b8536 100644 --- a/devices/base_device.py +++ b/devices/base_device.py @@ -29,6 +29,7 @@ class ChannelConfig: max_value: float = 100.0 enabled: bool = True 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..111224d 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( diff --git a/devices/nidaqmx_device.py b/devices/nidaqmx_device.py index 88bb932..38cf93d 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( diff --git a/devices/serial_device.py b/devices/serial_device.py index 067656a..8062624 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -264,6 +264,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) ] @@ -275,6 +276,8 @@ 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) ] @@ -284,8 +287,8 @@ class SerialDevice(BaseDevice): 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]), - ChannelConfig("cycle_units", "Cycle Units", "", 0.0, 1.0, color=_COLORS[3]), + 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"} @@ -311,6 +314,7 @@ class SerialDevice(BaseDevice): 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 @@ -321,6 +325,7 @@ class SerialDevice(BaseDevice): 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])] diff --git a/ui/control_editor.py b/ui/control_editor.py index 48de21d..5140bf3 100644 --- a/ui/control_editor.py +++ b/ui/control_editor.py @@ -360,8 +360,12 @@ 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: + if not ch.enabled or not ch.writable: + continue self._ch_cb.addItem(f"{ch.channel_id} ({ch.name})", userData=ch.channel_id) # For Arduino backends also suggest digital pins for output diff --git a/ui/control_panel.py b/ui/control_panel.py index 2b936c4..22229ee 100644 --- a/ui/control_panel.py +++ b/ui/control_panel.py @@ -120,7 +120,11 @@ class ControlWidget(QFrame): if self.registry and self.device_id and self.channel_id: dev = self.registry.get_instance(self.device_id) if dev: - if dev.status not in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED): + 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: -- cgit v1.2.3 From d9ca58e48cf92438087ac24ee261c2c4911316b1 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 29 Jul 2026 13:03:24 -0600 Subject: Add editable device display name, persist it across profile save/load DeviceInfo.name existed but was hardcoded per device type and never operator-editable. Adds a Display Name field to Add Device and makes the Info-tab Name field in the device config dialog editable, and threads the name through get_save_config()/ProfileManager.apply() so a custom name survives a .labdaq save/reload instead of reverting to the type default. Name is applied post-construction rather than as a constructor kwarg, since no device factory declares a "name" param. Co-Authored-By: Claude Sonnet 5 --- core/profile.py | 9 ++++++++- devices/analog_input.py | 1 + devices/arduino_device.py | 1 + devices/digital_io.py | 1 + devices/nidaqmx_device.py | 1 + devices/serial_device.py | 1 + plugins/motion_capture/device.py | 1 + ui/add_device_dialog.py | 8 ++++++++ ui/config_dialog.py | 9 ++++++++- ui/windows/devices_window.py | 2 +- 10 files changed, 31 insertions(+), 3 deletions(-) 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..1a7fefa 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, diff --git a/devices/arduino_device.py b/devices/arduino_device.py index 7c1d21b..e7fb537 100644 --- a/devices/arduino_device.py +++ b/devices/arduino_device.py @@ -203,6 +203,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, diff --git a/devices/digital_io.py b/devices/digital_io.py index 53395dc..954a8ac 100644 --- a/devices/digital_io.py +++ b/devices/digital_io.py @@ -258,6 +258,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, diff --git a/devices/nidaqmx_device.py b/devices/nidaqmx_device.py index 88bb932..ed19aeb 100644 --- a/devices/nidaqmx_device.py +++ b/devices/nidaqmx_device.py @@ -219,6 +219,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, diff --git a/devices/serial_device.py b/devices/serial_device.py index 067656a..57faee8 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -168,6 +168,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, 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/ui/add_device_dialog.py b/ui/add_device_dialog.py index 2956a96..68ec3ad 100644 --- a/ui/add_device_dialog.py +++ b/ui/add_device_dialog.py @@ -299,6 +299,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 +453,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/windows/devices_window.py b/ui/windows/devices_window.py index ac9d13c..2c484e8 100644 --- a/ui/windows/devices_window.py +++ b/ui/windows/devices_window.py @@ -349,7 +349,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): -- cgit v1.2.3 From 2c6b4b751ffdd1156254b31745597654b5f2e286 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 29 Jul 2026 13:10:35 -0600 Subject: Rework channel labeling and add source picker to virtual-channel creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relabel every channel picker/header to "NAME (DEVICE/SIGNAL)" instead of the previous "DEVICE/SIGNAL (NAME)" ordering, consistently across channels_window.py (_channel_combo, ChannelPickerDialog, ChannelPipelineBlock header), plot_builder.py, plot_config.py, control_editor.py, and plot_window.py. While touching each of those pickers, added the missing `.enabled` filter that _make_picker()/_x_cb/_build_channel_picker lacked (the default-layout builders already filtered disabled channels; these manual "add channel to pane" pickers didn't). Devices window Signals tab: removed the separate "Signal ID" column (folded into the existing non-editable Device column instead of the editable Name column, to avoid corrupting the in-place channel rename feature that column already supports). Channels window Virtual Channels pane: added a "Source" dropdown next to "+ Add Channel" — picking a source pre-seeds the new derived channel with it via DerivedBlock._add_src(); leaving it on the default "none" entry keeps today's behavior of creating an empty channel. Note: docs/ToDo.md's "Channels window UI" items referred to the *live* UI (this window's PipelineTab + the Devices window's Signals tab), not SignalsListTab in this same file, which its own module docstring flags as dead/unused code kept only for old-profile compatibility — verified by grepping for any instantiation of it (none exist). Co-Authored-By: Claude Sonnet 5 --- ui/control_editor.py | 2 +- ui/plot_builder.py | 8 ++++++-- ui/plot_config.py | 4 +++- ui/windows/channels_window.py | 20 +++++++++++++++----- ui/windows/devices_window.py | 25 ++++++++++--------------- ui/windows/plot_window.py | 16 ++++++++++++---- 6 files changed, 47 insertions(+), 28 deletions(-) diff --git a/ui/control_editor.py b/ui/control_editor.py index 48de21d..65aa96e 100644 --- a/ui/control_editor.py +++ b/ui/control_editor.py @@ -362,7 +362,7 @@ class ControlEditorDialog(QDialog): return # Add actual channels for ch in dev.info.channels: - self._ch_cb.addItem(f"{ch.channel_id} ({ch.name})", + 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/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/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/devices_window.py b/ui/windows/devices_window.py index ac9d13c..3b193b2 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) 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 -- cgit v1.2.3 From bf9c7746ba60613b66ce8d1e9a68c5480f2b93d1 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 29 Jul 2026 13:12:17 -0600 Subject: Sync Mark-10 gauge's live unit into ChannelConfig instead of a fixed default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark10Layer._parse() already tracked whichever unit suffix the gauge last reported (lb/kgF/N/ozF — the gauge's physical unit button cycles these independently of this app). serial_device.py hardcoded the "force" channel's unit to "N" and never updated it, so switching units on the gauge itself was invisible here. Adds Mark10Layer.current_unit and has SerialDevice.read_channels() keep the "force" ChannelConfig's unit in sync with it on every poll. This fixes the unit shown in anything that reads ChannelConfig.unit live — new plots, channel pickers, CSV log headers — but does not relabel the Y-axis of an already-open plot pane, since plot axis labels are baked into PlotConfig.y_label once at plot-build time, not re-read from the channel live. Making an open pane's axis relabel itself would need new signal plumbing from the device through AcquisitionEngine to the strip chart — a bigger, separate change. Co-Authored-By: Claude Sonnet 5 --- api_layers/protocols/mark10.py | 5 +++++ devices/serial_device.py | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/api_layers/protocols/mark10.py b/api_layers/protocols/mark10.py index a209232..48ed927 100644 --- a/api_layers/protocols/mark10.py +++ b/api_layers/protocols/mark10.py @@ -44,6 +44,11 @@ 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]: diff --git a/devices/serial_device.py b/devices/serial_device.py index 067656a..eadaa42 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -142,6 +142,14 @@ class SerialDevice(BaseDevice): raw = self._layer.read() if not raw: return {} + 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.) -- cgit v1.2.3 From ad7f1c39ef473a327ca3b467b579be0099377f55 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 29 Jul 2026 13:17:00 -0600 Subject: Make Run the master stop switch; add Log button disabled/recording styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run/Stop previously only paused acquisition — control outputs (motor speed, switches, PWM duty) kept whatever value was last written, so stopping the run loop didn't stop a running motor. Adds ControlWidget.safe_stop() (per-widget override, default zeros the output) and ControlPanel.safe_stop_all(), called from _toggle_run's Stop branch before engine.stop(). Per-widget behavior is deliberately not uniform: - OnOffSwitch/MotorControl/PwmControl have a latched running/enabled state, so safe_stop() drives their own toggle handler (consistent UI + write in one path) and, for Motor/PWM, zeroes the slider too. - SetpointControl/AnalogOutputControl only write on an explicit user action and have no universally safe forced value (e.g. 0 isn't necessarily "off" for an arbitrary process setpoint or analog output) — Stop leaves them untouched rather than guessing. Log button: added a :disabled QSS rule so "can't log yet" reads as clearly inert rather than a duller version of the enabled look, and a 600ms blink (toggling a "recording" dynamic property the QSS keys off) while a recording is active, so it reads as live/recording rather than a static pressed button. Master Stop now calls _toggle_log(False) explicitly when forcing the button off, since QPushButton.setChecked() doesn't emit clicked — without this the blink would keep running after a master Stop even though logging itself already halted via engine.stop()'s internal stop_logging() call. Co-Authored-By: Claude Sonnet 5 --- ui/control_panel.py | 40 ++++++++++++++++++++++++++++++++++++++++ ui/main_window.py | 18 +++++++++++++++++- ui/style_dark.qss | 2 ++ ui/style_light.qss | 2 ++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/ui/control_panel.py b/ui/control_panel.py index 2b936c4..4db18e3 100644 --- a/ui/control_panel.py +++ b/ui/control_panel.py @@ -154,6 +154,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 @@ -207,6 +222,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 @@ -294,6 +312,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 @@ -386,6 +408,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 @@ -455,6 +480,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 @@ -509,6 +538,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 @@ -566,6 +598,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 d0e0ac4..268a9d3 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -196,6 +196,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 _connect_signals(self): self.engine.new_data.connect(self.processor.on_raw_data) self.processor.processed_data.connect(self._chart.on_new_data) @@ -521,9 +524,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") @@ -539,8 +545,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 ────────────────────────────────────────────────── diff --git a/ui/style_dark.qss b/ui/style_dark.qss index e562365..9fd7198 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 e0a1987..265894d 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; } -- cgit v1.2.3 From 45ff7227fabb97273d4645601733f90f5f744eb1 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 29 Jul 2026 13:21:05 -0600 Subject: Check plugin dependencies before loading, warn instead of silent console error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest.json already had an unused "requires" field (motion_capture's manifest lists opencv-python>=4.8.0) — PluginManifest never parsed it, so a missing dependency just crashed the import inside _load_plugin(), caught by the broad except and only ever printed to the console. Adds PluginManifest.requires, PluginManager.missing_requirements()/ get_missing_dependencies(), and an early check in _load_plugin() that skips the risky import entirely when a requirement is missing. main_window.plugin_enable() now checks this before calling PluginManager.enable() and shows a QMessageBox with the missing packages and a pip install command instead of failing silently. Checks by distribution name via importlib.metadata (what pip installed it as), not import name — those differ for packages like opencv-python (imports as cv2) or pyserial (imports as serial), so importlib.util. find_spec() would give false negatives. Also fixes a button-state bug this surfaces: SettingsWindow's plugin toggle optimistically flipped to "Disable" the instant Enable was clicked, before knowing whether enabling actually succeeds — pre-existing, but now trivially reproducible (any plugin missing a dependency). Enable no longer flips the button immediately; main_window calls the new sync_plugin_button() once the real outcome is known. Co-Authored-By: Claude Sonnet 5 --- plugins/plugin_manager.py | 40 +++++++++++++++++++++++++++++++++++++++- ui/main_window.py | 20 ++++++++++++++++---- ui/windows/settings_window.py | 12 +++++++++++- 3 files changed, 66 insertions(+), 6 deletions(-) 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/ui/main_window.py b/ui/main_window.py index d0e0ac4..95cdb84 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -296,10 +296,22 @@ class MainWindow(QMainWindow): def plugin_enable(self, plugin_id: str): """Called by SettingsWindow when user enables a plugin.""" - ctx = self._make_plugin_context() - plugin = self._plugin_mgr.enable(plugin_id, ctx) - if plugin: - self._install_plugin(plugin) + missing = self._plugin_mgr.get_missing_dependencies(plugin_id) + if missing: + from PyQt6.QtWidgets import QMessageBox + QMessageBox.warning( + self, "Missing Plugin Dependencies", + f"Can't enable this plugin — missing Python packages:\n\n" + f" {', '.join(missing)}\n\n" + f"Install with:\n pip install {' '.join(missing)}" + ) + else: + ctx = self._make_plugin_context() + plugin = self._plugin_mgr.enable(plugin_id, ctx) + if plugin: + self._install_plugin(plugin) + if self._win_settings: + self._win_settings.sync_plugin_button(plugin_id) def plugin_disable(self, plugin_id: str): """Called by SettingsWindow when user disables a plugin.""" diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py index 1630efa..553a3a7 100644 --- a/ui/windows/settings_window.py +++ b/ui/windows/settings_window.py @@ -50,6 +50,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) @@ -233,6 +234,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) @@ -261,8 +263,16 @@ class SettingsWindow(QWidget): 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 ─────────────────────────────────────────────────────────── -- cgit v1.2.3 From 2298f779fdf7d828c1a84ca933b4eee9e8103212 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 29 Jul 2026 13:22:26 -0600 Subject: Offer to pip install missing plugin dependencies instead of just warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the previous branch's dependency check: the missing-deps QMessageBox is now a Yes/No prompt. Yes runs a blocking `sys.executable -m pip install ` (subprocess.run, output captured), shows a result dialog, and on success proceeds straight to enabling the plugin — no need to click Enable a second time. Blocking is a deliberate simplification, not an oversight: this codebase has no worker-thread/progress-dialog pattern for slow operations anywhere else, so a threaded installer would be inconsistent with everything else here. pip install is a one-time, infrequent action, unlike e.g. a serial connect that runs constantly. Co-Authored-By: Claude Sonnet 5 --- ui/main_window.py | 51 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/ui/main_window.py b/ui/main_window.py index 95cdb84..c04230c 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -299,20 +299,57 @@ class MainWindow(QMainWindow): missing = self._plugin_mgr.get_missing_dependencies(plugin_id) if missing: from PyQt6.QtWidgets import QMessageBox - QMessageBox.warning( + reply = QMessageBox.question( self, "Missing Plugin Dependencies", - f"Can't enable this plugin — missing Python packages:\n\n" + f"This plugin needs packages that aren't installed:\n\n" f" {', '.join(missing)}\n\n" - f"Install with:\n pip install {' '.join(missing)}" + 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: - ctx = self._make_plugin_context() - plugin = self._plugin_mgr.enable(plugin_id, ctx) - if plugin: - self._install_plugin(plugin) + 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) -- cgit v1.2.3 From cf33095f77e6ff902fb69932fa704eb6192b3418 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 29 Jul 2026 13:28:24 -0600 Subject: Add Developer Mode setting, Debug window, and gate simulate behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Settings > General > "Developer mode" checkbox (default on, so this repo's simulate-by-default workflow is unaffected out of the box) persisted through the existing core/app_settings.py load/save functions. Exposed to code that can't easily receive the settings dict via core.app_settings.is_developer_mode()/set_developer_mode(), a small in-memory cache MainWindow keeps in sync whenever settings are loaded or applied. Debug window (ui/windows/debug_window.py): minimal first pass per the plan — a live console. core/debug_log.py tees stdout/stderr into a ring buffer + Qt signal (installed once in main.py, before anything prints), so the window shows everything printed since app start, including from background poll threads, not just what's printed while it happens to be open. Its toolbar button is hidden unless developer mode is on. Simulate gating: every device's Simulation Mode checkbox/dropdown is now hidden when developer mode is off — ui/add_device_dialog.py (also covers the motion_capture camera panel, which shares this same checkbox rather than having its own), and the per-device config widgets in analog_input.py, arduino_device.py, digital_io.py, nidaqmx_device.py, serial_device.py. Hiding rather than force-clearing an already-simulating device's state — an existing simulated device keeps working if developer mode is turned off later; the option is just not offered again until it's back on. Co-Authored-By: Claude Sonnet 5 --- core/app_settings.py | 19 +++++++++++++ core/debug_log.py | 65 +++++++++++++++++++++++++++++++++++++++++++ devices/analog_input.py | 2 ++ devices/arduino_device.py | 2 ++ devices/digital_io.py | 2 ++ devices/nidaqmx_device.py | 2 ++ devices/serial_device.py | 6 +++- main.py | 2 ++ ui/add_device_dialog.py | 2 ++ ui/main_window.py | 31 ++++++++++++++++++++- ui/windows/debug_window.py | 65 +++++++++++++++++++++++++++++++++++++++++++ ui/windows/settings_window.py | 11 ++++++++ 12 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 core/debug_log.py create mode 100644 ui/windows/debug_window.py diff --git a/core/app_settings.py b/core/app_settings.py index e8bc7ae..ea3ac49 100644 --- a/core/app_settings.py +++ b/core/app_settings.py @@ -51,3 +51,22 @@ def save_settings(cfg: dict) -> None: json.dump(cfg, f, indent=2) except Exception: pass + + +# ── Developer mode ──────────────────────────────────────────────────────── +# +# In-memory cache so widgets that build device/config UI (Add Device dialog, +# per-device config panels, plugin panels) can check this without needing +# the full settings dict threaded through their constructors. MainWindow +# keeps it in sync with the persisted setting whenever settings are +# loaded/applied — see set_developer_mode() calls in ui/main_window.py. +_dev_mode = True + + +def is_developer_mode() -> bool: + return _dev_mode + + +def set_developer_mode(value: bool) -> None: + global _dev_mode + _dev_mode = value diff --git a/core/debug_log.py b/core/debug_log.py new file mode 100644 index 0000000..86a8a59 --- /dev/null +++ b/core/debug_log.py @@ -0,0 +1,65 @@ +""" +core/debug_log.py + +Tees stdout/stderr into an in-memory ring buffer + Qt signal so the Debug +window can show everything the app has printed since startup — including +messages from background poll threads (e.g. "[CMLLayer] poll error: ...") — +not just whatever gets printed while the window happens to be open. + +install() should be called once, early, before anything prints. The real +streams are still written to, so running from a terminal is unaffected. +""" + +from __future__ import annotations + +import sys +from collections import deque +from typing import Optional + +from PyQt6.QtCore import QObject, pyqtSignal + + +class _Broadcaster(QObject): + line_written = pyqtSignal(str) + + +class _StreamTee: + def __init__(self, real_stream, lines: deque, broadcaster: _Broadcaster): + self._real = real_stream + self._lines = lines + self._broadcaster = broadcaster + + def write(self, text: str) -> None: + self._real.write(text) + if text: + self._lines.append(text) + self._broadcaster.line_written.emit(text) + + def flush(self) -> None: + self._real.flush() + + def isatty(self) -> bool: + return False + + +_lines: Optional[deque] = None +_broadcaster: Optional[_Broadcaster] = None + + +def install(max_lines: int = 2000) -> None: + """Redirect sys.stdout/sys.stderr through the tee. Safe to call once.""" + global _lines, _broadcaster + if _broadcaster is not None: + return + _lines = deque(maxlen=max_lines) + _broadcaster = _Broadcaster() + sys.stdout = _StreamTee(sys.stdout, _lines, _broadcaster) + sys.stderr = _StreamTee(sys.stderr, _lines, _broadcaster) + + +def get_broadcaster() -> Optional[_Broadcaster]: + return _broadcaster + + +def get_history() -> str: + return "".join(_lines) if _lines is not None else "" diff --git a/devices/analog_input.py b/devices/analog_input.py index c9e5d2a..6ac82d4 100644 --- a/devices/analog_input.py +++ b/devices/analog_input.py @@ -250,6 +250,8 @@ class AnalogInputConfigWidget(QWidget): self.sim_check = QCheckBox("Simulation Mode (no hardware)") self.sim_check.setChecked(self.device.simulate) + from core.app_settings import is_developer_mode + self.sim_check.setVisible(is_developer_mode()) be_form.addRow(self.sim_check) root.addWidget(be_grp) diff --git a/devices/arduino_device.py b/devices/arduino_device.py index 7c1d21b..954d387 100644 --- a/devices/arduino_device.py +++ b/devices/arduino_device.py @@ -287,6 +287,8 @@ class ArduinoConfigWidget(QWidget): self.sim_chk = QCheckBox("Simulation Mode (no hardware)") self.sim_chk.setChecked(self.device.simulate) + from core.app_settings import is_developer_mode + self.sim_chk.setVisible(is_developer_mode()) ser_form.addRow(self.sim_chk) scan_row = QHBoxLayout() diff --git a/devices/digital_io.py b/devices/digital_io.py index 53395dc..2bd0243 100644 --- a/devices/digital_io.py +++ b/devices/digital_io.py @@ -290,6 +290,8 @@ class DigitalIOConfigWidget(QWidget): be_form.addRow("Backend:", self.be_cb) self.sim_chk = QCheckBox("Simulate") self.sim_chk.setChecked(self.device.simulate) + from core.app_settings import is_developer_mode + self.sim_chk.setVisible(is_developer_mode()) be_form.addRow(self.sim_chk) self.ni_dev_edit = QLineEdit(self.device._ni_device) diff --git a/devices/nidaqmx_device.py b/devices/nidaqmx_device.py index 88bb932..3f64998 100644 --- a/devices/nidaqmx_device.py +++ b/devices/nidaqmx_device.py @@ -294,6 +294,8 @@ class NidaqmxConfigWidget(QWidget): self.sim_chk = QCheckBox("Simulation Mode (no hardware)") self.sim_chk.setChecked(self.device.simulate) + from core.app_settings import is_developer_mode + self.sim_chk.setVisible(is_developer_mode()) ni_form.addRow(self.sim_chk) scan_row = QHBoxLayout() diff --git a/devices/serial_device.py b/devices/serial_device.py index 067656a..bd953b8 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -448,7 +448,11 @@ class SerialConfigWidget(QWidget): self._sim_cb = QComboBox() self._sim_cb.addItems(["Simulate", "Real Hardware"]) self._sim_cb.setCurrentIndex(0 if self.device.simulate else 1) - conn_form.addRow("Mode:", self._sim_cb) + from core.app_settings import is_developer_mode + mode_lbl = QLabel("Mode:") + dev_mode = is_developer_mode() + mode_lbl.setVisible(dev_mode); self._sim_cb.setVisible(dev_mode) + conn_form.addRow(mode_lbl, self._sim_cb) root.addWidget(conn_grp) diff --git a/main.py b/main.py index b023b99..ede6277 100644 --- a/main.py +++ b/main.py @@ -13,11 +13,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from PyQt6.QtWidgets import QApplication from ui.main_window import MainWindow +from core.debug_log import install as install_debug_log def main(): app = QApplication(sys.argv) app.setApplicationName("LabDAQ") + install_debug_log() # tee stdout/stderr for the Debug window, before anything prints qss = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "style_dark.qss") if os.path.exists(qss): diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py index 2956a96..4b2b373 100644 --- a/ui/add_device_dialog.py +++ b/ui/add_device_dialog.py @@ -17,6 +17,7 @@ from devices.device_registry import DeviceRegistry from devices.arduino_device import ArduinoDevice from devices.nidaqmx_device import NidaqmxDevice from devices.serial_device import SerialDevice, _FORMAT_LABELS +from core.app_settings import is_developer_mode # ── Background scan threads ─────────────────────────────────────────────────── @@ -276,6 +277,7 @@ class AddDeviceDialog(QDialog): conn_form.addRow(self._ni_lbl, self._ni_edit) self._sim_chk = QCheckBox("Simulation mode") + self._sim_chk.setVisible(is_developer_mode()) # dev-mode-only escape hatch conn_form.addRow(self._sim_chk) root.addLayout(conn_form) diff --git a/ui/main_window.py b/ui/main_window.py index d0e0ac4..ca4274c 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -45,7 +45,7 @@ from ui.windows.settings_window import SettingsWindow from plugins.plugin_manager import PluginManager from plugins.base_plugin import PluginContext -from core.app_settings import load_settings, save_settings +from core.app_settings import load_settings, save_settings, set_developer_mode _DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss") @@ -68,6 +68,7 @@ class MainWindow(QMainWindow): self._win_channels = None self._win_plot = None self._win_settings = None + self._win_debug = None _plugins_dir = os.path.join(os.path.dirname(os.path.dirname( os.path.abspath(__file__))), "plugins") @@ -171,6 +172,12 @@ class MainWindow(QMainWindow): set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn)) tb.addWidget(set_btn); self._btn_settings = set_btn + debug_btn = QPushButton("🐞 Debug") + debug_btn.setObjectName("toolbarSectionBtn"); debug_btn.setCheckable(True) + debug_btn.clicked.connect(lambda c: self._toggle_win("debug", c, debug_btn)) + tb.addWidget(debug_btn); self._btn_debug = debug_btn + debug_btn.setVisible(False) # shown/hidden per developer-mode setting + # ── Central ─────────────────────────────────────────────────────── central = QWidget(); self.setCentralWidget(central) @@ -314,12 +321,14 @@ class MainWindow(QMainWindow): "channels": self._open_channels, "plot": self._open_plot, "settings": self._open_settings, + "debug": self._open_debug, } wins = { "devices": "_win_devices", "channels": "_win_channels", "plot": "_win_plot", "settings": "_win_settings", + "debug": "_win_debug", } if checked: creators[name]() @@ -370,6 +379,22 @@ class MainWindow(QMainWindow): self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False)) self._show_win(self._win_settings, "right") + def _open_debug(self): + from ui.windows.debug_window import DebugWindow + if self._win_debug is None: + self._win_debug = DebugWindow(self) + self._win_debug.closed.connect(lambda: self._btn_debug.setChecked(False)) + self._show_win(self._win_debug, "right") + + def _update_debug_btn_visibility(self): + from core.app_settings import is_developer_mode + on = is_developer_mode() + self._btn_debug.setVisible(on) + if not on: + self._btn_debug.setChecked(False) + if self._win_debug: + self._win_debug.hide() + def _show_win(self, win: QWidget, position: str = "right"): if not win.isVisible(): screen = QApplication.screenAt(self.geometry().center()) @@ -554,10 +579,14 @@ class MainWindow(QMainWindow): def _apply_settings_on_startup(self): self._apply_theme(self._settings.get("theme", "dark")) self.engine._interval = self._settings.get("poll_ms", 100) / 1000.0 + set_developer_mode(self._settings.get("developer_mode", True)) + self._update_debug_btn_visibility() def _on_settings(self, cfg: dict): self._settings.update(cfg) self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 + set_developer_mode(self._settings.get("developer_mode", True)) + self._update_debug_btn_visibility() save_settings(self._settings) def _tick(self): diff --git a/ui/windows/debug_window.py b/ui/windows/debug_window.py new file mode 100644 index 0000000..0e891ed --- /dev/null +++ b/ui/windows/debug_window.py @@ -0,0 +1,65 @@ +""" +ui/windows/debug_window.py + +DEBUG window — developer-mode only. + +Minimal first pass: a live console showing everything the app has printed +via core.debug_log (stdout/stderr tee), so debugging doesn't require a +terminal. Not wired to any other diagnostics yet — extend as needed. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QTextEdit, QFrame, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFont, QCloseEvent + +from core.debug_log import get_broadcaster, get_history + + +class DebugWindow(QWidget): + closed = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.setWindowTitle("Debug") + self.setMinimumSize(560, 420) + self.resize(700, 500) + self._build() + + broadcaster = get_broadcaster() + if broadcaster is not None: + broadcaster.line_written.connect(self._append) + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0) + + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14, 0, 14, 0) + title = QLabel("DEBUG"); title.setObjectName("devWindowTitle") + hl.addWidget(title, 1) + clear_btn = QPushButton("Clear"); clear_btn.setObjectName("configButton") + clear_btn.clicked.connect(lambda: self._console.clear()) + hl.addWidget(clear_btn) + root.addWidget(hdr) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + self._console = QTextEdit(); self._console.setObjectName("codeEditor") + self._console.setReadOnly(True) + mono = QFont("IBM Plex Mono, Consolas, Monospace") + mono.setStyleHint(QFont.StyleHint.Monospace) + self._console.setFont(mono) + self._console.setPlainText(get_history()) + self._console.verticalScrollBar().setValue(self._console.verticalScrollBar().maximum()) + root.addWidget(self._console, 1) + + def _append(self, text: str): + self._console.insertPlainText(text) + sb = self._console.verticalScrollBar() + sb.setValue(sb.maximum()) + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py index 1630efa..e24da7f 100644 --- a/ui/windows/settings_window.py +++ b/ui/windows/settings_window.py @@ -40,6 +40,7 @@ class SettingsWindow(QWidget): "show_grid": True, "font_size": 12, "antialias": True, + "developer_mode": True, } def __init__(self, registry: DeviceRegistry, @@ -108,6 +109,14 @@ class SettingsWindow(QWidget): self._aa_chk = QCheckBox(); self._aa_chk.setChecked(self.cfg["antialias"]) lay.addRow("Anti-alias plots:", self._aa_chk) + self._dev_chk = QCheckBox() + self._dev_chk.setChecked(self.cfg["developer_mode"]) + self._dev_chk.setToolTip( + "Controls whether the Debug window and each device's Simulation\n" + "Mode option are available. Off = real-hardware-only, no debug tools." + ) + lay.addRow("Developer mode:", self._dev_chk) + rst = QPushButton("Reset to Defaults"); rst.setObjectName("configButton") rst.clicked.connect(self._reset_to_defaults) lay.addRow("", rst) @@ -120,6 +129,7 @@ class SettingsWindow(QWidget): self._theme_cb.setCurrentText(self.cfg["theme"].title()) self._font_sp.setValue(self.cfg["font_size"]) self._aa_chk.setChecked(self.cfg["antialias"]) + self._dev_chk.setChecked(self.cfg["developer_mode"]) self._poll_sp.setValue(self.cfg["poll_ms"]) self._buf_sp.setValue(self.cfg["buffer_size"]) self._log_edit.setText(self.cfg["log_dir"]) @@ -288,6 +298,7 @@ class SettingsWindow(QWidget): "theme": theme, "font_size": self._font_sp.value(), "antialias": self._aa_chk.isChecked(), + "developer_mode": self._dev_chk.isChecked(), "poll_ms": self._poll_sp.value(), "buffer_size": self._buf_sp.value(), "log_dir": self._log_edit.text(), -- cgit v1.2.3 From f1aaffbc3eb1e2c154315c556d2555803eea7997 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Fri, 31 Jul 2026 15:07:46 -0600 Subject: updated requirements comments. --- requirements.txt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 -- cgit v1.2.3