fix(xrayeye): make the pixel clock slider index-based over supported values

The server-side snap-to-nearest fix (previous commit) corrected what
got written to hardware, but the slider itself still let an operator
drag to -- and briefly display -- any integer in [min, max] before
self-correcting on the next status message, which reads as "the slider
allows any setting" even though bad ones don't stick.

pixel_clock_slider's range is now [0, len(options)-1], an index into
the real discrete list of supported pixel clocks (fetched once over
RPC at widget startup via get_pixel_clock_list(), disabled until it
arrives -- not a polling loop, this list never changes at runtime).
Every position the slider can physically be dragged to is therefore
one the hardware has already confirmed it accepts.
getting_camera_status() now maps the hardware-reported MHz value to
the nearest option's index instead of setting the slider to a raw MHz
value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5
This commit is contained in:
x01dcandClaude Sonnet 5 authored and x12sa committed 2026-09-15 08:31:52 +02:00
1 parent a550fd4f03
commit afca16bfe3
1 file changed
+86 -19
@@ -280,6 +280,12 @@ class OMNY_XRayEye(BECWidget, QWidget):
super().__init__(parent=parent, **kwargs)
self._live_view_signal = CAMERA[1]
self._last_smear_composite = None
# pixel_clock_slider is index-based over this list (populated once,
# see _init_pixel_clock_options()) rather than a raw MHz range --
# many uEye sensors only accept a short discrete list of pixel
# clocks, not every value in [min, max] (confirmed on hardware).
self._pixel_clock_options: list[int] = []
self._last_pixel_clock_mhz: int | None = None
self._connected_motor = None
self._dap_params_forwarding_connected = False
self._queue_busy = False
@@ -322,6 +328,7 @@ class OMNY_XRayEye(BECWidget, QWidget):
self.resize(800, 600)
QTimer.singleShot(0, self._init_queue_status)
QTimer.singleShot(0, self._init_gui_trigger)
QTimer.singleShot(0, self._init_pixel_clock_options)
def _init_ui(self):
self.root_layout = QVBoxLayout(self)
@@ -467,9 +474,18 @@ class OMNY_XRayEye(BECWidget, QWidget):
# Pixel clock (MHz): lowering it raises the max achievable exposure
# time (at the cost of frame rate) -- exposed here since operators
# hitting the exposure slider's ceiling need this knob to go further.
# Many uEye sensors only accept a short discrete list of pixel
# clocks (confirmed on hardware -- most values in the driver's own
# reported min/max range were rejected), so this slider's *position*
# is an index into self._pixel_clock_options, not a raw MHz value --
# every position it can land on is therefore one the hardware
# actually accepts. The real list is fetched once, over RPC, in
# _init_pixel_clock_options() (it's static per camera, not something
# that needs a live subscription); disabled until then.
self.pixel_clock_label = QLabel("Pixel clock", parent=self)
self.pixel_clock_slider = QSlider(Qt.Orientation.Horizontal, parent=self)
self.pixel_clock_slider.setRange(1, 100) # MHz; placeholder, reseeded on connect
self.pixel_clock_slider.setRange(0, 0) # placeholder; see _set_pixel_clock_options()
self.pixel_clock_slider.setEnabled(False)
self.pixel_clock_value_label = QLabel("-- MHz", parent=self)
self.pixel_clock_value_label.setMinimumWidth(60)
self.pixel_clock_slider.valueChanged.connect(self._update_pixel_clock_value_label)
@@ -987,23 +1003,16 @@ class OMNY_XRayEye(BECWidget, QWidget):
# No auto_gain_enabled handling here -- see the comment above the
# (removed) toggle in _init_ui.
# Reseed the pixel-clock slider's bounds/value before the exposure
# ones below -- changing the pixel clock changes the exposure range,
# and both arrive together in this same message.
pixel_clock_min = signals.get(f"{CAMERA[0]}_pixel_clock_min")
pixel_clock_max = signals.get(f"{CAMERA[0]}_pixel_clock_max")
if pixel_clock_min is not None and pixel_clock_max is not None:
lo = int(pixel_clock_min.get("value"))
hi = int(pixel_clock_max.get("value"))
if (lo, hi) != (self.pixel_clock_slider.minimum(), self.pixel_clock_slider.maximum()):
self.pixel_clock_slider.setRange(lo, hi)
# Update the pixel-clock slider's position before the exposure ones
# below -- changing the pixel clock changes the exposure range, and
# both arrive together in this same message. (No range/bounds to
# reseed here -- pixel_clock_slider's range is fixed once
# self._pixel_clock_options is populated; see _init_pixel_clock_
# options()/_set_pixel_clock_options(). pixel_clock_min/max still
# exist on the device as informational metadata, just unused here.)
pixel_clock = signals.get(f"{CAMERA[0]}_pixel_clock")
if pixel_clock is not None:
self.pixel_clock_slider.blockSignals(True)
self.pixel_clock_slider.setValue(int(pixel_clock.get("value")))
self.pixel_clock_slider.blockSignals(False)
self._update_pixel_clock_value_label(self.pixel_clock_slider.value())
self._set_pixel_clock_display(int(pixel_clock.get("value")))
# Reseed the exposure slider's bounds next (rare -- only changes if
# the pixel clock changed), so a value arriving in the same message
@@ -1049,11 +1058,69 @@ class OMNY_XRayEye(BECWidget, QWidget):
value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE
self.dev.get(CAMERA[0]).exposure_time.put(value_ms)
def _update_pixel_clock_value_label(self, value: int):
self.pixel_clock_value_label.setText(f"{value} MHz")
def _init_pixel_clock_options(self):
"""One-time fetch of the camera's actually-supported pixel clocks
(see get_pixel_clock_list()'s docstring for why this can't just be
[min, max] from a Kind.config signal) -- not a polling loop, this
list is static per camera, so a single RPC call at widget startup is
the right cost/benefit trade-off versus adding a whole signal path
for something that never changes at runtime."""
try:
options = self.dev.get(CAMERA[0]).get_pixel_clock_list()
except Exception as exc:
logger.warning(f"Failed to fetch pixel clock options for OMNY_XRayEye: {exc}")
return
self._set_pixel_clock_options(sorted(int(v) for v in options))
def _set_pixel_clock_options(self, options: list[int]):
self._pixel_clock_options = options
if not options:
self.pixel_clock_slider.setEnabled(False)
return
self.pixel_clock_slider.blockSignals(True)
self.pixel_clock_slider.setRange(0, len(options) - 1)
self.pixel_clock_slider.blockSignals(False)
self.pixel_clock_slider.setEnabled(True)
if self._last_pixel_clock_mhz is not None:
# A status message already arrived before this RPC call
# returned -- apply it now that we can actually resolve it to a
# slider position.
self._set_pixel_clock_display(self._last_pixel_clock_mhz)
else:
# No status message yet either -- keep the label in sync with
# the slider's default position (index 0, the lowest option).
self._update_pixel_clock_value_label(self.pixel_clock_slider.value())
def _set_pixel_clock_display(self, mhz: int):
"""Move the slider to the option nearest `mhz` (from hardware, e.g.
via getting_camera_status()) and update the value label -- never a
raw MHz value on the slider itself, only a valid index."""
self._last_pixel_clock_mhz = mhz
if not self._pixel_clock_options:
# Options not fetched yet -- _set_pixel_clock_options() will
# call back into this once they arrive.
self.pixel_clock_value_label.setText(f"{mhz} MHz")
return
index = min(
range(len(self._pixel_clock_options)),
key=lambda i: abs(self._pixel_clock_options[i] - mhz),
)
self.pixel_clock_slider.blockSignals(True)
self.pixel_clock_slider.setValue(index)
self.pixel_clock_slider.blockSignals(False)
self._update_pixel_clock_value_label(index)
def _update_pixel_clock_value_label(self, index: int):
if 0 <= index < len(self._pixel_clock_options):
self.pixel_clock_value_label.setText(f"{self._pixel_clock_options[index]} MHz")
else:
self.pixel_clock_value_label.setText("-- MHz")
def pixel_clock_submitted(self):
self.dev.get(CAMERA[0]).pixel_clock.put(self.pixel_clock_slider.value())
index = self.pixel_clock_slider.value()
if not 0 <= index < len(self._pixel_clock_options):
return
self.dev.get(CAMERA[0]).pixel_clock.put(self._pixel_clock_options[index])
@SafeSlot(bool)
def opening_shutter(self, enabled: bool):