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:
+57
-125
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user