summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api_layers/protocols/cml.py12
-rw-r--r--ui/control_panel.py70
2 files changed, 74 insertions, 8 deletions
diff --git a/api_layers/protocols/cml.py b/api_layers/protocols/cml.py
index 113afe7..e085adf 100644
--- a/api_layers/protocols/cml.py
+++ b/api_layers/protocols/cml.py
@@ -149,6 +149,16 @@ class CMLLayer(BaseProtocol):
self._send(motor.address, "]")
self._read_reply()
elif cmd == "VS":
+ # Motor must be enabled ("(") or the shaft stays free and
+ # every move command below is silently accepted but
+ # produces no motion — see docs/CM1-C_ASCII_Command_
+ # Cheatsheet.md "Safety and enable commands". Enabling
+ # an already-enabled motor is a no-op, so it's safe to
+ # send unconditionally on every move rather than relying
+ # on the operator to have pressed a separate M1_ME
+ # control first.
+ self._send(motor.address, "(")
+ self._read_reply()
self._send(motor.address, "P0", _CONTINUOUS_POSITION)
self._read_reply()
self._send(motor.address, "S0", int(value))
@@ -156,6 +166,8 @@ class CMLLayer(BaseProtocol):
self._send(motor.address, "^")
self._read_reply()
elif cmd == "MA":
+ self._send(motor.address, "(")
+ self._read_reply()
self._send(motor.address, "P0", int(value))
self._read_reply()
self._send(motor.address, "^")
diff --git a/ui/control_panel.py b/ui/control_panel.py
index f0e18ef..dafeabf 100644
--- a/ui/control_panel.py
+++ b/ui/control_panel.py
@@ -21,6 +21,7 @@ from __future__ import annotations
from typing import Optional
import math as _math
+import threading
from typing import Any
from PyQt6.QtWidgets import (
@@ -36,6 +37,57 @@ from devices.device_registry import DeviceRegistry
from devices.base_device import DeviceStatus
+# ── Background write dispatch ───────────────────────────────────────────────
+#
+# write_channel() is synchronous and, for some protocols (e.g. CML — see
+# api_layers/protocols/cml.py), does multiple blocking serial round-trips
+# per call — each one can cost up to the serial port's full read timeout if
+# the device doesn't reply to that particular command, so a single write
+# can take seconds. Control widgets call this directly from Qt signal
+# handlers (slider valueChanged fires on every tick of a drag), so on the
+# GUI thread that means each tick blocks the whole app.
+#
+# Writes are coalesced per (device, channel) — not just queued — keyed in
+# this dict rather than a plain FIFO queue: a slider drag fires far more
+# ticks than a multi-second write can keep up with, and a strict FIFO would
+# just work through an ever-growing backlog of stale values (observed: 30+
+# queued while only ~4 had drained), so the motor keeps "moving" to
+# seconds-old positions long after the user stopped touching the slider —
+# looks exactly like a freeze. Re-writing the same key updates its pending
+# value in place without changing its position, so the worker always sends
+# the latest value once it's free, never a backlog of old ones.
+
+_write_lock = threading.Lock()
+_write_cond = threading.Condition(_write_lock)
+_pending: "dict" = {} # (device_id, channel_id) -> (dev, channel_id, value)
+
+
+def _enqueue_write(dev, channel_id, value):
+ with _write_cond:
+ _pending[(dev.info.device_id, channel_id)] = (dev, channel_id, value)
+ _write_cond.notify()
+
+
+def _write_worker():
+ while True:
+ with _write_cond:
+ while not _pending:
+ _write_cond.wait()
+ key = next(iter(_pending))
+ dev, channel_id, value = _pending.pop(key)
+ try:
+ ok = dev.write_channel(channel_id, value)
+ if not ok:
+ print(f"[Control] write_channel({channel_id}, {value}) "
+ f"returned False on {dev.info.device_id} — "
+ f"check device type and channel ID")
+ except Exception as e:
+ print(f"[Control] write_channel({channel_id}, {value}) raised: {e}")
+
+
+threading.Thread(target=_write_worker, daemon=True, name="ControlWrite").start()
+
+
# ══════════════════════════════════════════════════════════════════════════════
# Base widget
# ══════════════════════════════════════════════════════════════════════════════
@@ -176,8 +228,13 @@ class ControlWidget(QFrame):
DigitalIODevice → write_channel() → ArduinoLayer.digital_write() → W:Dxx:val
AnalogInputDevice (arduino backend) → write_channel() → ArduinoLayer.digital_write()
Any device with no write support → logs a warning, emits signal only
+
+ The actual write is dispatched to a background worker (_enqueue_write)
+ rather than called directly here — some protocols (CML) do multiple
+ blocking serial round-trips per write, and this runs on the GUI
+ thread (slider valueChanged fires on every tick of a drag), so a
+ direct call here would freeze the whole app on every tick.
"""
- written = False
if self.registry and self.device_id and self.channel_id:
dev = self.registry.get_instance(self.device_id)
if dev:
@@ -189,13 +246,10 @@ class ControlWidget(QFrame):
print(f"[Control] write_channel({self.channel_id}, {value}) skipped on "
f"{self.device_id} — device status is {dev.status.value}, not connected")
else:
- 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")
+ _enqueue_write(dev, self.channel_id, value)
+ else:
+ print(f"[Control] write_channel({self.channel_id}, {value}) skipped — "
+ f"device '{self.device_id}' not found in registry")
self.value_changed.emit(self.channel_id, value)
if self._on_action_fn is not None: