summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-08-17 10:02:28 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-08-17 10:02:28 -0600
commit2014b045fc62567aeb06ea610a98aa03a43b7142 (patch)
tree1370aa2ccf4f8b946910881556a00a0885637152 /ui
parentabab8f7938dada9a2abff31e719186b99934601a (diff)
Fix control panel freeze and CM1 motor not moving
ControlWidget._write() called dev.write_channel() directly on the GUI thread from Qt signal handlers — for CML (CoolMuscle), write() does 2-3 blocking serial round-trips, and a slider fires valueChanged on every drag tick, so each tick froze the whole app until the device responded or timed out. Replaced with a coalescing background dispatch: writes are keyed by (device, channel) in a dict instead of a plain FIFO queue, so re-writing the same channel while a write is still pending updates its value in place rather than piling up — a strict FIFO was observed backing up 30+ deep during a single drag, meaning the motor kept "moving" to seconds-old positions long after the slider stopped, which looked like a freeze even once off the GUI thread. Separately, the motor never actually moved because it was never enabled: per docs/CM1-C_ASCII_Command_Cheatsheet.md, a CM1-C motor stays disabled ("shaft free") until sent "(", and nothing in CMLLayer.write()'s VS/MA handling ever sent it — enable only existed as a separate M1_ME channel the generic MotorControl widget has no concept of. VS/MA now send enable unconditionally before the move sequence (a no-op if already enabled). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'ui')
-rw-r--r--ui/control_panel.py70
1 files changed, 62 insertions, 8 deletions
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: