summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--api_layers/protocols/base_protocol.py1
-rw-r--r--api_layers/protocols/cml.py120
-rw-r--r--api_layers/protocols/mark10.py27
-rw-r--r--api_layers/protocols/modbus_rtu.py18
-rw-r--r--api_layers/protocols/scpi.py15
-rw-r--r--devices/serial_device.py45
-rw-r--r--docs/CM1-C_ASCII_Command_Cheatsheet.md189
-rw-r--r--plugins/enabled.json2
-rw-r--r--ui/control_panel.py17
10 files changed, 364 insertions, 76 deletions
diff --git a/.gitignore b/.gitignore
index ac18dcf..7a03d40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,8 @@ logs/*
__pychache__/
*.pyc
*.pyo
-.venv/ \ No newline at end of file
+.claude/settings.local.json
+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 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..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)
@@ -43,8 +48,11 @@ 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.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 +84,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 +101,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/devices/serial_device.py b/devices/serial_device.py
index 458aec8..067656a 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())
@@ -154,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:
@@ -276,16 +281,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 +303,26 @@ 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
+ # 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)],
+ ))
+ color_idx += 1
return channels or [ChannelConfig("M1_TP", "M1 TP", "counts", color=_COLORS[0])]
return []
@@ -793,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()
@@ -856,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 \".<id>\"")
lay.addWidget(QLabel("Addr:"))
lay.addWidget(self._addr_spin)
diff --git a/docs/CM1-C_ASCII_Command_Cheatsheet.md b/docs/CM1-C_ASCII_Command_Cheatsheet.md
new file mode 100644
index 0000000..9fdd695
--- /dev/null
+++ b/docs/CM1-C_ASCII_Command_Cheatsheet.md
@@ -0,0 +1,189 @@
+# Cool Muscle CM1-C ASCII / CML Serial Control Cheat Sheet
+
+**Applies to:** CM1-C, with RT3.14-focused commands
+**Serial default:** 38400 baud, 8-N-1, no flow control
+**Terminator:** carriage return (`\r`, ASCII 13) after every command
+**Motor ID:** append `.1`, `.2`, etc. Always include it explicitly.
+
+> Use low speed and low torque during commissioning. Software commands are not a safety-rated E-stop.
+
+## Quick direct move
+
+```text
+M0.1=20
+A0.1=10
+S0.1=20
+P0.1=1000
+^.1
+```
+
+- `P0` target position in pulses
+- `S0` speed; actual unit is selected by `K37`
+- `A0` acceleration in thousands of pulses/s^2
+- `M0` torque limit, 0-100% of peak torque
+- `^` execute
+
+## Continuous rotation until stopped
+
+```text
+A0.1=10
+M0.1=30
+P0.1=1000000000
+S0.1=20
+^.1
+```
+
+Use negative speed for the opposite direction:
+
+```text
+S0.1=-20
+^.1
+```
+
+Stop:
+
+```text
+].1
+```
+
+`S0.1=0` also stops indefinite-position motion. Exact CW/CCW depends on `K45`.
+
+## Safety and enable commands
+
+| Command | Action |
+|---|---|
+| `].1` | Immediate normal software stop of motor 1; pauses a bank |
+| `*` | Emergency stop all motors on the chain |
+| `*1` | Clear emergency-stop state |
+| `).1` | Disable motor; shaft becomes free |
+| `(.1` | Enable motor |
+
+## Queries
+
+| Command | Information |
+|---|---|
+| `?.1` | Dynamic P0/A0/S0 |
+| `?85.1` | Firmware and motor ID |
+| `?90.1` | All K parameters |
+| `?91.1` / `?P.1` | Position registers |
+| `?92.1` / `?S.1` | Speed registers |
+| `?93.1` / `?A.1` | Acceleration registers |
+| `?95.1` | Position error |
+| `?96.1` | Current position |
+| `?97.1` | Current speed |
+| `?98.1` | Averaged current |
+| `?99.1` | Motor status |
+| `?70.1` | Input status |
+| `?71.1` | Temperature |
+| `?74.1` | Analog input |
+| `?1000.1` | All program and logic banks |
+| `K37.1` | Query one specific parameter |
+
+## Status values from `?99`
+
+- `0` moving
+- `1` position-error overflow
+- `2` overspeed/overvoltage
+- `4` overload/overcurrent
+- `8` in position / ready
+- `16` disabled
+- `32` push torque reached
+- `128` overtemperature
+- `256` push target reached before expected resistance
+- `512` emergency stop
+
+Values can be combined as a bit field.
+
+## Zeroing and homing
+
+| Command | Action |
+|---|---|
+| `|.1` | Run configured origin search |
+| `|1.1` | Move to position zero |
+| `|2.1` | Assign current position as zero |
+| `|4.1` | Soft reset |
+| `|11.1` | Clear whole-revolution count |
+
+The character is pipe `|` (ASCII 124), not capital I. Configure `K42`, `K43`, `K45`, `K46`, `K47`, and `K48` before origin search.
+
+## Outputs
+
+- `O1.1`, `O2.1`: output on
+- `F1.1`, `F2.1`: output off
+- `?51.1`, `?52.1`: output status
+- Configure output behavior in `K34`
+
+## Stored registers
+
+- `P1-P25`: positions
+- `S1-S15`: speeds
+- `A1-A8`: accelerations
+- `M1-M8`: torque limits
+- `T1-T8`: millisecond timers
+- `V1-V15`: variables/internal-state mappings
+- `N1-N25`, `R1-R25`: coordinated-motion or general-use data
+
+## Program banks
+
+```text
+B1
+S1,A1,P1
+END
+```
+
+- `[1.1`: execute program bank 1
+- `[L1.1`: execute logic bank 1
+- `].1`: pause immediately; send twice to terminate
+- `}.1`: stop after current motion
+- `>.1`, `<.1`: step through paused program
+- `]L.1`: stop logic bank
+- `B100`, `L100`: clear all banks
+- `$ .1` without the space: save to EEPROM; actual command is `$.1`
+
+## Important K parameters
+
+- `K20`: baud / ASCII versus Modbus
+- `K23`: serial event reporting and echo
+- `K37`: resolution and speed unit; default `K37=3` = 1000 ppr, 100 pps speed unit
+- `K44`: deceleration ratio
+- `K45`: direction and coordinate sign
+- `K55`: in-position tolerance
+- `K56`: position-error fault threshold
+- `K58`, `K59`: software position limits
+- `K70`: CR-only versus CR+LF replies
+
+K/H parameters auto-save by default. On suitable RT3.14 firmware, `_SKH=0` temporarily disables auto-saving to avoid repeated EEPROM writes; `_SKH=1` re-enables it.
+
+## PowerShell
+
+```powershell
+$PortName = "COM3"
+
+$cm = [System.IO.Ports.SerialPort]::new(
+ $PortName, 38400,
+ [System.IO.Ports.Parity]::None,
+ 8,
+ [System.IO.Ports.StopBits]::One
+)
+$cm.Handshake = [System.IO.Ports.Handshake]::None
+$cm.ReadTimeout = 700
+$cm.WriteTimeout = 700
+$cm.NewLine = "`r"
+$cm.Open()
+
+function Send-CM1 {
+ param(
+ [Parameter(Mandatory)][string]$Command,
+ [int]$WaitMs = 150
+ )
+ if (-not $script:cm -or -not $script:cm.IsOpen) {
+ throw "CM1 serial port is not open."
+ }
+ $null = $script:cm.ReadExisting()
+ $script:cm.Write($Command + "`r")
+ Start-Sleep -Milliseconds $WaitMs
+ $script:cm.ReadExisting()
+}
+```
+
+**Source basis:** Myostat CM1-C User Guide v3.00 (2025-12-05) and CM1 RT3.14 Quick Reference Guide v3.14.00.
diff --git a/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
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: