Phase 5: shared worker base, self-rescheduling polls, driver robustness

gui/qt_workers.py — one QueueWorker base replaces the per-device command
queue + dispatch + signal boilerplate. The loop blocks on the queue
instead of waking 10-20x/second forever (test_idle_worker_does_not_spin
asserts an idle worker burns ~no CPU). PollingQueueWorker adds
self-rescheduling polling: the next poll is queued only after the
previous finishes, so a device slower than the interval can't accumulate
a backlog (test_polling_never_overlaps_or_backs_up).

Helios responsiveness — the concrete bug that motivated the above: a free
running 1 s QTimer queued a status poll that took ~2 s, so the queue grew
for as long as the panel stayed connected.
- helios_laser._query reads until the CR terminator instead of sleeping a
  fixed 0.05 + 0.2 s per query
- one _query_int() helper replaces five copies of parse-with-logging
- polling is now driven by the worker; HeliosWindow's QTimer is gone
- dropped __del__, which disabled the laser and wrote to the serial port
  from the garbage collector at an unpredictable time

helios_test_app.py — the worker was moveToThread'd but every call site
invoked its methods directly, so all serial I/O (including the sleeps)
ran on the GUI thread; Query All froze the UI for ~2 s. Calls now go
through a queued signal to a pyqtSlot. Also: connect/disconnect cycles
leaked a QThread + worker + 9 connections each time; 16 copies of the
not-connected guard collapse to _require_connection(); the Query Power
button called a method that has never existed (AttributeError popup) and
is now disabled and documented in KNOWN_ISSUES.

DCBiasImageWidget preallocates its image and uses set_data/set_clim, so
the live preview stops rebuilding the array and the whole artist tree per
row (O(rows^2) over a scan).

bbd20x: connect() now raises when no bays respond instead of reporting
success on the wrong port; disconnect() joins with a timeout so a wedged
reader can't hang shutdown; one _channel_for() helper replaces four
copy-pasted axis mappings; hardcoded travel limits become TRAVEL_MM; the
joke error strings are gone.

gui/widgets.py adds the shared ConnectionBar / PortSelector / bounded
LogConsole / StatusGrid for the test benches to adopt. 65 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-28 11:21:29 -05:00
parent afe33249d1
commit 44febe34b8
8 changed files with 833 additions and 444 deletions
+57 -125
View File
@@ -115,37 +115,36 @@ class HeliosLaser:
return False
def _query(self, command: str) -> Optional[str]:
"""
Send a query and read response.
"""Send a query and return the value from its response.
Args:
command: ASCII query command (without CR or ?)
Returns:
Response value string or None if error
Reads until the CR terminator rather than sleeping a fixed interval:
the device usually answers in a few ms, so the old unconditional
0.05 + 0.2 s cost ~250 ms per query and made an 8-query status poll
take ~2 s — longer than the 1 s interval that scheduled it.
"""
try:
# Clear any pending data in the buffer
# Clear any stale bytes so a previous timed-out reply can't be
# mistaken for this command's response.
self.serial.reset_input_buffer()
time.sleep(0.05)
if not self._send_command(command):
return None
time.sleep(0.2) # Give device time to respond
response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip()
if not response:
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
return None
logger.debug(f"Query '{command}' response: {response}")
# Helios format: "COMMAND = VALUE UNIT"
# Extract just the value part
# Helios format: "COMMAND = VALUE UNIT" — take just the value
if '=' in response:
parts = response.split('=')
if len(parts) >= 2:
value_part = parts[1].strip()
# Remove unit suffix if present (e.g., "ns", "mA", "mW")
value = value_part.split()[0]
return value
# Strip the unit suffix if present (e.g. "ns", "mA", "mW")
fields = value_part.split()
if fields:
return fields[0]
return response
@@ -153,6 +152,17 @@ class HeliosLaser:
logger.error(f"Failed to read response for '{command}': {e}")
return None
def _query_int(self, command: str) -> Optional[int]:
"""Query a value that should parse as an int; None if absent/unparseable."""
raw = self._query(command)
if raw is None:
return None
try:
return int(raw)
except ValueError:
logger.error(f"Query '{command}' returned non-integer {raw!r}")
return None
def set_frequency_hz(self, frequency: int) -> bool:
"""
Set laser pulse frequency in Hz.
@@ -228,60 +238,24 @@ class HeliosLaser:
return success
def is_laser_enabled(self) -> bool:
"""
Check if laser is currently enabled.
Returns:
True if laser is enabled
"""
response = self._query("LDO")
if response:
try:
return int(response) == 1
except ValueError:
logger.error(f"Invalid response for LDO: {response}")
return False
"""True if laser emission is currently enabled."""
return self._query_int("LDO") == 1
def get_frequency_hz(self) -> Optional[int]:
"""
Get current laser frequency in Hz.
Returns:
Frequency in Hz or None if error
"""
response = self._query("LDF")
if response:
try:
period_ns = int(response)
return int(1e9 / period_ns)
except (ValueError, ZeroDivisionError):
logger.error(f"Invalid response for LDF: {response}")
return None
"""Current laser frequency in Hz, or None on error."""
period_ns = self._query_int("LDF")
if not period_ns:
return None
return int(1e9 / period_ns)
def get_current_ma(self) -> Optional[int]:
"""
Get current pump diode current in mA.
Returns:
Current in mA or None if error
"""
response = self._query("LDS")
if response:
try:
return int(response)
except ValueError:
logger.error(f"Invalid response for LDS: {response}")
return None
"""Current pump diode current in mA, or None on error."""
return self._query_int("LDS")
def _query_millicelsius(self, command: str) -> Optional[float]:
"""Query a temperature register (returns milli-°C) and convert to °C."""
response = self._query(command)
if response:
try:
return int(response) / 1000.0
except ValueError:
logger.error(f"Invalid response for {command}: {response}")
return None
"""Query a temperature register (milli-°C) and convert to °C."""
value = self._query_int(command)
return None if value is None else value / 1000.0
def get_diode_temp_c(self) -> Optional[float]:
"""Diode temperature in °C (LTA, 5000–50000 milli-°C)."""
@@ -296,46 +270,22 @@ class HeliosLaser:
return self._query_millicelsius("EOA")
def get_controller_serial(self) -> Optional[str]:
"""
Get controller serial number.
Returns:
Serial number string or None if error
"""
"""Controller serial number, or None on error."""
return self._query("CSR")
def get_head_serial(self) -> Optional[str]:
"""
Get laser head serial number.
Returns:
Serial number string or None if error
"""
"""Laser head serial number, or None on error."""
return self._query("HSR")
def get_status_registers(self) -> tuple:
"""Query the LER, LCE and CCE status registers.
Each is a bitmask (sum of flags); non-zero means active faults,
cleared with reset_faults(). Returns (ler, lce, cce), any of which
is None if that register could not be read.
"""
Query LER, LCE, and CCE status registers.
Each register is a bitmask (sum of flags). Non-zero values indicate
active faults. Reset with reset_faults().
Returns:
Tuple of (ler, lce, cce) as ints, or None for each on error.
"""
def _read_reg(cmd):
resp = self._query(cmd)
if resp is not None:
try:
return int(resp)
except ValueError:
logger.error(f"Invalid response for {cmd}: {resp}")
return None
ler = _read_reg("LER")
lce = _read_reg("LCE")
cce = _read_reg("CCE")
return (ler, lce, cce)
return (self._query_int("LER"), self._query_int("LCE"),
self._query_int("CCE"))
def reset_faults(self) -> bool:
"""
@@ -357,38 +307,19 @@ class HeliosLaser:
return ok
def get_remote_enable(self) -> Optional[bool]:
"""
Query the remote enable state (LRE - activates utility connector pin 8).
Returns:
True if remote enable is active, False if not, None on error
"""
response = self._query("LRE")
if response is not None:
try:
return int(response) == 1
except ValueError:
logger.error(f"Invalid response for LRE: {response}")
return None
"""Remote enable state (LRE — utility connector pin 8); None on error."""
value = self._query_int("LRE")
return None if value is None else value == 1
def send_raw_command(self, command: str) -> Optional[str]:
"""
Send a raw command string and return the raw response.
Useful for diagnostics. Sends *command* + CR, waits briefly,
then reads whatever the device returns (up to the first CR or timeout).
Returns:
Raw response string (decoded, stripped) or None on error.
"""
"""Send a raw command and return the unparsed response (diagnostics)."""
if not self.is_connected or not self.serial:
logger.error("Not connected to laser")
return None
try:
self.serial.reset_input_buffer()
time.sleep(0.05)
self.serial.write((command + '\r').encode('ascii'))
time.sleep(0.3)
if not self._send_command(command):
return None
raw = self.serial.read_until(b'\r')
if not raw:
raw = self.serial.read(self.serial.in_waiting)
@@ -410,6 +341,7 @@ class HeliosLaser:
command = f"LRE {1 if enable else 0}"
return self._send_command(command)
def __del__(self):
"""Destructor - ensure cleanup"""
self.disconnect()
# No __del__: it used to call disconnect(), which disables the laser and
# writes to the serial port from the garbage collector at an
# unpredictable time (including interpreter shutdown, when the port may
# already be torn down). Callers close the driver explicitly.