summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-07-28 17:01:38 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-07-28 17:01:38 -0600
commitb2dc94bb2e7c7e57cba202697ed4d58bbb1f01f5 (patch)
tree3c495f951024f3a69113137bc065769e4428a513
parent96ef35db552d26e6d962c9ff6f9da5c928c0ad76 (diff)
Update CML protocol documentation and improve command handling; adjust motion_capture setting to false
-rw-r--r--api_layers/protocols/cml.py118
-rw-r--r--api_layers/protocols/mark10.py9
-rw-r--r--devices/serial_device.py10
-rw-r--r--plugins/enabled.json2
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:
+ <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,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: "<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
@@ -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 \".<id>\"")
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