perf(bec_widgets): fix SampleStorageWidget poll lag
Replace ~44 per-slot blocking device reads per poll with a single bulk flomni_samples.read(), and add change-detection so cells only repaint when their state actually changes. The old per-slot accessors stalled the GUI event loop every 2s; steady-state polling now does no UI work. Writes invalidate the changed slot's cache so local edits still repaint immediately.
This commit was merged in pull request #251.
This commit is contained in:
@@ -198,6 +198,8 @@ class SampleStorageWidget(BECWidget, QWidget):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
self._cells: dict[int, _SlotCell] = {}
|
||||
# last-seen (occupied, name) per slot, for change-detection in refresh()
|
||||
self._last_state: dict[int, tuple[bool, str]] = {}
|
||||
self._flomni_available = self._check_flomni_available()
|
||||
self._build_ui()
|
||||
self._poll_timer = QTimer(self)
|
||||
@@ -227,19 +229,45 @@ class SampleStorageWidget(BECWidget, QWidget):
|
||||
"""The flomni_samples device (single source of truth)."""
|
||||
return self.dev.flomni_samples
|
||||
|
||||
def _read_slot(self, slot: int) -> tuple[bool, str]:
|
||||
"""Return (occupied, name) for a storage slot 0–20 or the gripper."""
|
||||
def _read_all_slots(self) -> dict[int, tuple[bool, str]]:
|
||||
"""Return {slot: (occupied, name)} for every slot 0–20 and the gripper
|
||||
from a SINGLE bulk ``dev.flomni_samples.read()`` round-trip.
|
||||
|
||||
The per-slot accessors (``is_sample_slot_used`` +
|
||||
``sample_names.sample{N}.get()``) would be ~44 blocking device
|
||||
round-trips per poll, which stalled the GUI event loop. ``read()``
|
||||
returns the whole device's signals at once; we index into that dict
|
||||
using the flat signal-key convention BEC uses for this device
|
||||
(``flomni_samples_sample_placed_sample{N}`` /
|
||||
``flomni_samples_sample_names_sample{N}`` /
|
||||
``flomni_samples_sample_in_gripper`` /
|
||||
``flomni_samples_sample_in_gripper_name``), the same keys the CLI
|
||||
iterated over in ftransfer_sample_change.
|
||||
"""
|
||||
result: dict[int, tuple[bool, str]] = {}
|
||||
try:
|
||||
if slot == GRIPPER_SLOT:
|
||||
occupied = bool(self._samples.is_sample_in_gripper())
|
||||
name = self._samples.sample_in_gripper_name.get()
|
||||
else:
|
||||
occupied = bool(self._samples.is_sample_slot_used(slot))
|
||||
name = getattr(self._samples.sample_names, f"sample{slot}").get()
|
||||
return occupied, (name if name else EMPTY_NAME)
|
||||
data = self._samples.read()
|
||||
except Exception as exc:
|
||||
logger.warning(f"SampleStorageWidget: read of slot {slot} failed: {exc}")
|
||||
return False, EMPTY_NAME
|
||||
logger.warning(f"SampleStorageWidget: bulk read failed: {exc}")
|
||||
# keep whatever is currently displayed rather than blanking out
|
||||
return {}
|
||||
|
||||
def _val(key, default=None):
|
||||
entry = data.get(key)
|
||||
if isinstance(entry, dict):
|
||||
return entry.get("value", default)
|
||||
return default
|
||||
|
||||
for slot in (STAGE_SLOT, *STORAGE_SLOTS):
|
||||
used = _val(f"flomni_samples_sample_placed_sample{slot}", 0)
|
||||
name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME)
|
||||
result[slot] = (bool(used), name if name else EMPTY_NAME)
|
||||
|
||||
g_used = _val("flomni_samples_sample_in_gripper", 0)
|
||||
g_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME)
|
||||
result[GRIPPER_SLOT] = (bool(g_used), g_name if g_name else EMPTY_NAME)
|
||||
|
||||
return result
|
||||
|
||||
def _write_slot(self, slot: int, used: int, name: str) -> bool:
|
||||
"""
|
||||
@@ -253,6 +281,10 @@ class SampleStorageWidget(BECWidget, QWidget):
|
||||
else:
|
||||
getattr(self._samples.sample_placed, f"sample{slot}").set(used)
|
||||
getattr(self._samples.sample_names, f"sample{slot}").set(name)
|
||||
# drop the cached state for this slot so the refresh() right after
|
||||
# a write always repaints it, without waiting for the value to
|
||||
# differ from a possibly-stale cache entry
|
||||
self._last_state.pop(slot, None)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(f"SampleStorageWidget: write of slot {slot} failed: {exc}")
|
||||
@@ -325,12 +357,27 @@ class SampleStorageWidget(BECWidget, QWidget):
|
||||
|
||||
@SafeSlot()
|
||||
def refresh(self) -> None:
|
||||
"""Re-read every slot from the device and update the cells."""
|
||||
"""Re-read all slots (one bulk device round-trip) and update only the
|
||||
cells whose state actually changed.
|
||||
|
||||
Repainting every cell on every 2 s poll — even when nothing changed,
|
||||
which is almost always the case for a sample magazine — was needless
|
||||
work on the GUI thread. We diff against the last-seen state and only
|
||||
call set_state() on cells that differ, so a steady-state poll does no
|
||||
UI work at all.
|
||||
"""
|
||||
if not self._flomni_available:
|
||||
return
|
||||
for slot, cell in self._cells.items():
|
||||
occupied, name = self._read_slot(slot)
|
||||
cell.set_state(occupied, name)
|
||||
state = self._read_all_slots()
|
||||
if not state:
|
||||
return # bulk read failed; leave the current display untouched
|
||||
for slot, (occupied, name) in state.items():
|
||||
if self._last_state.get(slot) == (occupied, name):
|
||||
continue
|
||||
cell = self._cells.get(slot)
|
||||
if cell is not None:
|
||||
cell.set_state(occupied, name)
|
||||
self._last_state[slot] = (occupied, name)
|
||||
|
||||
# ── mutation actions (called from _SlotCell context menu) ─────────────────
|
||||
|
||||
@@ -389,4 +436,4 @@ class SampleStorageWidget(BECWidget, QWidget):
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._poll_timer.stop()
|
||||
super().cleanup()
|
||||
super().cleanup()
|
||||
Reference in New Issue
Block a user