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 --- 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 ++ 4 files changed, 11 insertions(+), 1 deletion(-) (limited to 'api_layers/protocols') 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 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(-) (limited to 'api_layers/protocols') 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 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(-) (limited to 'api_layers/protocols') 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 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(+) (limited to 'api_layers/protocols') 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