diff options
| author | Christian Kolset <ckolset@colostate.edu> | 2026-08-17 10:02:28 -0600 |
|---|---|---|
| committer | Christian Kolset <ckolset@colostate.edu> | 2026-08-17 10:02:28 -0600 |
| commit | 2014b045fc62567aeb06ea610a98aa03a43b7142 (patch) | |
| tree | 1370aa2ccf4f8b946910881556a00a0885637152 /ui | |
| parent | abab8f7938dada9a2abff31e719186b99934601a (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.py | 70 |
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: |
