pre uc480 integration
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# ScanEngine-3 — Claude Code Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
Primary language is Python with PyQt6 for all GUIs. This project consists of hardware test apps and control software for cameras, lasers, and motor drivers communicating over serial/USB/UART. Also includes RP2040 C/PIO firmware for embedded peripherals.
|
||||
|
||||
When generating test apps or utilities, use PyQt6 patterns consistent with existing apps in the project (see `helios_test_app.py`, `bbd202_test_app.py`, `camera_test_app.py`).
|
||||
|
||||
## Hardware Debugging
|
||||
|
||||
This project involves hardware test apps (cameras, lasers, motor drivers) communicating over serial/USB/UART. When debugging hardware issues, always consider these common root causes before proposing code changes:
|
||||
|
||||
- **Device held by another process** — check with `lsof /dev/ttyUSB*` or `fuser /dev/ttyUSB*` before diagnosing driver bugs
|
||||
- **USB re-enumeration** — device may re-appear on a different `/dev` path after a reset; don't assume the port is stable
|
||||
- **Firmware state that persists across soft resets** — registers, enable bits, and error latches may retain values from a previous run; always read current state before assuming defaults
|
||||
|
||||
When working with hardware protocol docs (PDFs), register definitions and command specs may be split across multiple documents. Ask the user early if the needed section might be in a separate document rather than assuming all info is in one file.
|
||||
|
||||
### PIO (RP2040)
|
||||
|
||||
When writing or debugging PIO programs, proactively check for these common pitfalls:
|
||||
|
||||
- **`pull noblock`** loads OSR from X (scratch register) if the TX FIFO is empty — if X is uninitialised this silently clobbers the output value
|
||||
- **Invisible pulses** — a single-cycle high pulse is often too narrow to trigger a scope; use a counter to hold the output high for several cycles
|
||||
- **Off-by-one errors** in division/counting loops — verify the total cycle count matches the intended division ratio including the branch instruction cost
|
||||
@@ -0,0 +1,145 @@
|
||||
# Helios Laser Test Application
|
||||
|
||||
A simple PyQt6 GUI application for testing and controlling the Helios pulsed laser system.
|
||||
|
||||
## Features
|
||||
|
||||
- **Connection Management**: Connect/disconnect to the laser via RS-232 serial port
|
||||
- **Frequency Control**: Set and query laser pulse frequency (16,700 - 125,000 Hz)
|
||||
- **Current Control**: Set and query pump diode current (0 - 7,000 mA)
|
||||
- **Pulse Mode**: Select from three pulse modes:
|
||||
- Single Pulse
|
||||
- Continuous Gating
|
||||
- Continuous Pulsing
|
||||
- **Laser Enable/Disable**: Control laser emission with dedicated buttons
|
||||
- **System Information**: Query and display controller and head serial numbers
|
||||
- **Power Monitoring**: Query and display output power in mW
|
||||
- **Message Log**: Real-time log of operations and responses
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the Application
|
||||
|
||||
```bash
|
||||
/opt/scanengine-3/run_helios_test.sh
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```bash
|
||||
source /opt/srasenv/bin/activate
|
||||
cd /opt/scanengine-3
|
||||
python3 helios_test_app.py
|
||||
```
|
||||
|
||||
### Connection
|
||||
|
||||
1. **Select Serial Port**: Choose the appropriate serial port from the dropdown (e.g., `/dev/ttyUSB0`)
|
||||
2. **Refresh Ports**: Click "Refresh Ports" to update the list of available ports
|
||||
3. **Connect**: Click "Connect" to establish connection with the laser
|
||||
4. Once connected, the status will show "Connected to [port]" in green
|
||||
|
||||
### Control Tab
|
||||
|
||||
#### Frequency Control
|
||||
- Enter desired frequency (16,700 - 125,000 Hz)
|
||||
- Click **Set Frequency** to apply the setting
|
||||
- Click **Query** to read the current frequency from the laser
|
||||
|
||||
#### Current Control
|
||||
- Enter desired pump diode current (0 - 7,000 mA)
|
||||
- Click **Set Current** to apply the setting
|
||||
- Click **Query** to read the current from the laser
|
||||
|
||||
#### Pulse Mode Control
|
||||
- Select desired pulse mode from dropdown
|
||||
- Click **Set Mode** to apply the setting
|
||||
|
||||
#### Laser Control
|
||||
- **Enable Laser**: Starts laser emission
|
||||
- **Disable Laser**: Stops laser emission
|
||||
- **Query Status**: Checks if laser is currently enabled/disabled
|
||||
|
||||
### Monitor Tab
|
||||
|
||||
#### System Information
|
||||
- **Query All**: Queries all available parameters from the laser
|
||||
- **Controller SN**: Displays controller serial number
|
||||
- **Head SN**: Displays laser head serial number
|
||||
|
||||
#### Power Monitoring
|
||||
- **Query Power**: Reads output power in mW
|
||||
|
||||
#### Messages
|
||||
- Real-time log of all operations and responses
|
||||
- Useful for debugging and verifying commands
|
||||
|
||||
## RS-232 Communication Settings
|
||||
|
||||
The driver uses the following settings (automatic, no configuration needed):
|
||||
- **Baud Rate**: 9600
|
||||
- **Data Bits**: 8
|
||||
- **Parity**: None
|
||||
- **Stop Bits**: 1
|
||||
- **Flow Control**: None
|
||||
- **Timeout**: 1 second
|
||||
|
||||
## Command Reference
|
||||
|
||||
The application uses the following Helios commands:
|
||||
|
||||
| Command | Function | Range |
|
||||
|---------|----------|-------|
|
||||
| `FP=value` | Set frequency period (ns) | 8,000 - 60,000 |
|
||||
| `FP?` | Query frequency period | - |
|
||||
| `PC=value` | Set pump current (mA) | 0 - 7,000 |
|
||||
| `PC?` | Query pump current | - |
|
||||
| `PM=value` | Set pulse mode (0-2) | 0=Single, 1=Gate, 2=Continuous |
|
||||
| `LE=value` | Set laser enable (0/1) | 0=Off, 1=On |
|
||||
| `LE?` | Query laser enable status | - |
|
||||
| `PO?` | Query output power (mW) | - |
|
||||
| `SN?` | Query controller serial | - |
|
||||
| `HSN?` | Query head serial | - |
|
||||
|
||||
## Safety Notes
|
||||
|
||||
⚠️ **IMPORTANT**: Please follow these safety precautions when using the laser:
|
||||
|
||||
1. **Door Switch**: Ensure the laser enclosure door switch is closed during operation
|
||||
2. **Laser Disable Pin**: The laser disable pin (pin 3) must be properly connected
|
||||
3. **Residual Emission**: The laser may emit when the door switch is open - always treat it as potentially dangerous
|
||||
4. **Start Low**: When setting current, start at lower values and increase gradually
|
||||
5. **Monitor Power**: Use the "Query Power" function to verify safe output levels
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot Connect
|
||||
- Verify the correct serial port is selected
|
||||
- Check that the USB-to-RS-232 adapter is properly connected
|
||||
- Ensure no other application is using the same port
|
||||
- Try clicking "Refresh Ports" to update the list
|
||||
|
||||
### Commands Not Working
|
||||
- Verify the laser is connected (status shows green)
|
||||
- Check that the laser is not in error state
|
||||
- Review the message log for error details
|
||||
|
||||
### Serial Numbers Not Showing
|
||||
- The laser may not respond if not properly powered
|
||||
- Check the serial connection
|
||||
- Try the "Query All" button to get diagnostic information
|
||||
|
||||
## Files
|
||||
|
||||
- `helios_test_app.py` - Main application
|
||||
- `run_helios_test.sh` - Launcher script
|
||||
- `hardware/helios_laser.py` - Helios driver
|
||||
- `docs/protocols/helios_comms_protocol.pdf` - Official Helios documentation
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- PyQt6
|
||||
- pyserial
|
||||
- Helios laser with RS-232 interface
|
||||
- USB-to-RS-232 adapter (if using modern computer)
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# ADC YOFF Sign Bug — sras_viewer.py
|
||||
|
||||
## Status
|
||||
Fix applied, awaiting user testing.
|
||||
|
||||
## What was wrong
|
||||
|
||||
`DC_YOFF_ADC` in `sras_viewer.py` was `+87.04` instead of `-87.04`.
|
||||
|
||||
The Tektronix scope stores CH3/CH4 waveform data as **signed int8** (−128 to +127), where ADC 0 = screen center. The scope's vertical position for CH3/CH4 is set to `−2.72 div` in `sc3_aui_app.py`, which places 0 V **below** center at ADC count `−2.72 × 32 = −87.04`. The comment in the code had the formula as `-position × (256/8)` (sign flipped), producing `+87.04` instead of the correct `−87.04`.
|
||||
|
||||
## Effect of the bug
|
||||
|
||||
- `adc_to_mv` was off by 272 mV in the negative direction
|
||||
- ADC −87 (true 0 V signal) → −272 mV (should be ≈ 0 mV)
|
||||
- ADC 0 (screen center, above ground) → −136 mV (should be +136 mV)
|
||||
- DC images for CH3/CH4 (Bias A/B) showed large negative voltages, physically impossible for DC bias signals
|
||||
- RF mask threshold (`mv_to_adc`) was also broken: threshold ADC value ~+87 was being compared against pixel means clustered around −87, so nearly every pixel would have been incorrectly masked
|
||||
|
||||
## The fix
|
||||
|
||||
`sras_viewer.py` line 48:
|
||||
```python
|
||||
# Before
|
||||
DC_YOFF_ADC = 87.04 # ADC count that represents 0 V
|
||||
|
||||
# After
|
||||
DC_YOFF_ADC = -87.04 # ADC count that represents 0 V
|
||||
```
|
||||
Comment on line 47 also corrected from `-position × (256/8)` to `position × (256/8)`.
|
||||
|
||||
## What to verify during testing
|
||||
|
||||
1. CH3 and CH4 DC images show positive (or near-zero) voltages consistent with the bias signal levels
|
||||
2. RF (CH1) image is not excessively masked — pixels with a genuine bias signal above the threshold should appear
|
||||
3. `mv_to_adc(0.0)` should now return −87.04 (not +87.04)
|
||||
4. The default threshold of 0.125 mV should correspond to ADC ≈ −87.0, not +87.1
|
||||
@@ -0,0 +1,545 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Scanengine 3 Main Application
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from PyQt6 import QtWidgets, QtCore
|
||||
from typing import Optional
|
||||
import serial.tools.list_ports
|
||||
|
||||
from hardware.coherent_hops_laser import CoherentHOPSLaser, DummyLaser
|
||||
from hardware.helios_laser import HeliosLaser, PulseMode
|
||||
from hardware.uc480_camera import UC480Camera, CameraStreamThread
|
||||
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y, TriggerBitsServo
|
||||
from motion_worker import MotionWorker
|
||||
from scanning.stage_scan_plan_generator import StageScanPlanGenerator
|
||||
from genesis_worker import GenesisWorker, GenesisCommand
|
||||
from ui_mainwindow import Ui_MainWindow
|
||||
|
||||
# Page indices in stackedWidget
|
||||
PAGE_START = 0
|
||||
PAGE_OPTIONS = 1
|
||||
PAGE_NEWSCAN = 2
|
||||
PAGE_CONTINUESCAN = 3
|
||||
PAGE_SCAN_PROGRESS = 4
|
||||
|
||||
CONFIG_PATH = Path(__file__).parent / "config.json"
|
||||
DEFAULT_CONFIG = {
|
||||
"stage": {
|
||||
"serial_port": "",
|
||||
"trigger": "Disabled",
|
||||
"scan_velocity_mm_s": 200.0,
|
||||
"scan_acceleration_mm_s2": 500.0,
|
||||
"optical_axis_x_mm": 0.0,
|
||||
"optical_axis_y_mm": 0.0,
|
||||
},
|
||||
"fpga": {
|
||||
"serial_port": "",
|
||||
"pulse_divider": 1,
|
||||
"rowpack_enabled": False,
|
||||
},
|
||||
"t3r": {
|
||||
"serial_port": "",
|
||||
"t_axis_current_ma": 0.0,
|
||||
"gr_axis_current_ma": 0.0,
|
||||
"t_axis_microstepping": "Full Step",
|
||||
"gr_axis_microstepping": "Full Step",
|
||||
},
|
||||
"oscilloscope": {
|
||||
"ip_address": "",
|
||||
},
|
||||
"generation_laser": {
|
||||
"serial_port": "",
|
||||
"pulse_frequency_hz": 125000,
|
||||
"diode_pump_current_ma": 0.0,
|
||||
},
|
||||
"detection_laser": {
|
||||
"power_mw": 0.0,
|
||||
},
|
||||
"genesis_laser": {
|
||||
"com_port": "/dev/ttyUSB0",
|
||||
},
|
||||
}
|
||||
|
||||
# Fixed option lists for combo boxes
|
||||
TRIGGER_OPTIONS = [
|
||||
"Disabled",
|
||||
"Trigger Out: In Motion",
|
||||
"Trigger Out: Motion Complete",
|
||||
"Trigger Out: Max Velocity",
|
||||
"Trigger Out: High at Max Velocity",
|
||||
]
|
||||
|
||||
MICROSTEPPING_OPTIONS = [
|
||||
"Full Step",
|
||||
"Half Step",
|
||||
"1/4 Step",
|
||||
"1/8 Step",
|
||||
"1/16 Step",
|
||||
"1/32 Step",
|
||||
]
|
||||
|
||||
|
||||
class ScanWorker(QtCore.QObject):
|
||||
"""Worker object for handling scanning in a separate thread."""
|
||||
|
||||
scan_started = QtCore.pyqtSignal()
|
||||
scan_completed = QtCore.pyqtSignal()
|
||||
scan_failed = QtCore.pyqtSignal(str)
|
||||
angle_started = QtCore.pyqtSignal(int, int)
|
||||
line_started = QtCore.pyqtSignal(int, int, float)
|
||||
current_progress = QtCore.pyqtSignal(int)
|
||||
overall_progress = QtCore.pyqtSignal(int)
|
||||
status_message = QtCore.pyqtSignal(str)
|
||||
|
||||
def __init__(self, scan_params, motion_worker):
|
||||
super().__init__()
|
||||
self.scan_params = scan_params
|
||||
self.motion_worker = motion_worker
|
||||
self.should_stop = False
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def run_scan(self):
|
||||
"""Execute the full scanning process."""
|
||||
if self.motion_worker:
|
||||
self.motion_worker.scanning_active = True
|
||||
try:
|
||||
self.scan_started.emit()
|
||||
# TODO: implement scan execution logic
|
||||
self.scan_completed.emit()
|
||||
except Exception as e:
|
||||
self.scan_failed.emit(str(e))
|
||||
finally:
|
||||
if self.motion_worker:
|
||||
self.motion_worker.scanning_active = False
|
||||
|
||||
def stop(self):
|
||||
self.should_stop = True
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.ui = Ui_MainWindow()
|
||||
self.ui.setupUi(self)
|
||||
|
||||
self.config = self._load_config()
|
||||
|
||||
# Hardware objects
|
||||
self.motion_worker: Optional[MotionWorker] = None
|
||||
self.motion_thread: Optional[QtCore.QThread] = None
|
||||
self.genesis_worker: Optional[GenesisWorker] = None
|
||||
self.genesis_thread: Optional[QtCore.QThread] = None
|
||||
self.camera: Optional[UC480Camera] = None
|
||||
self.camera_stream: Optional[CameraStreamThread] = None
|
||||
self.vis_laser: Optional[CoherentHOPSLaser] = None
|
||||
self.ir_laser: Optional[HeliosLaser] = None
|
||||
self.scan_worker: Optional[ScanWorker] = None
|
||||
self.scan_thread: Optional[QtCore.QThread] = None
|
||||
|
||||
self._connect_signals()
|
||||
self._init_genesis_worker()
|
||||
self.ui.stackedWidget.setCurrentIndex(PAGE_START)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
if CONFIG_PATH.exists():
|
||||
try:
|
||||
with open(CONFIG_PATH) as f:
|
||||
cfg = json.load(f)
|
||||
for section, values in DEFAULT_CONFIG.items():
|
||||
cfg.setdefault(section, {})
|
||||
for key, val in values.items():
|
||||
cfg[section].setdefault(key, val)
|
||||
return cfg
|
||||
except Exception:
|
||||
pass
|
||||
return {k: dict(v) for k, v in DEFAULT_CONFIG.items()}
|
||||
|
||||
def _save_config(self):
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(self.config, f, indent=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Signal wiring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _connect_signals(self):
|
||||
# Start page
|
||||
self.ui.start_new_scan_btn.clicked.connect(self._go_to_newscan)
|
||||
self.ui.resume_scan_btn.clicked.connect(self._go_to_continuescan)
|
||||
self.ui.edit_options_btn.clicked.connect(self._go_to_options)
|
||||
|
||||
# Options page
|
||||
self.ui.options_save_settings_btn.clicked.connect(self._on_options_save)
|
||||
self.ui.options_cancel_btn.clicked.connect(self._go_to_start)
|
||||
self.ui.stage_test_connection_btn.clicked.connect(self._on_test_stage_connection)
|
||||
self.ui.fpga_connect_button.clicked.connect(self._on_fpga_connect)
|
||||
self.ui.fpga_refresh_ports_btn.clicked.connect(self._on_fpga_refresh_ports)
|
||||
self.ui.refresh_serial_ports_btn.clicked.connect(self._on_refresh_serial_ports)
|
||||
self.ui.scope_connect_btn.clicked.connect(self._on_scope_connect)
|
||||
self.ui.generation_connect_button.clicked.connect(self._on_generation_connect)
|
||||
self.ui.detection_test_btn.clicked.connect(self._on_detection_test)
|
||||
self.ui.t3r_connect_btn.clicked.connect(self._on_t3r_connect)
|
||||
self.ui.t3r_refresh_ports_btn.clicked.connect(self._on_t3r_refresh_ports)
|
||||
|
||||
# New scan page
|
||||
self.ui.newscan_browse_folders_btn.clicked.connect(self._on_newscan_browse)
|
||||
self.ui.newscan_set_current_as_start_btn.clicked.connect(self._on_newscan_set_start)
|
||||
self.ui.newscan_get_delta_from_current_btn.clicked.connect(self._on_newscan_get_delta)
|
||||
self.ui.newscan_toggle_vis_laser_btn.clicked.connect(self._on_newscan_toggle_vis_laser)
|
||||
self.ui.newscan_continue_to_next_btn.clicked.connect(self._on_newscan_start_scan)
|
||||
self.ui.newscan_jog_x_pos_btn.pressed.connect(self._on_jog_x_pos_pressed)
|
||||
self.ui.newscan_jog_x_pos_btn.released.connect(self._on_jog_stop)
|
||||
self.ui.newscan_jog_x_neg_btn.pressed.connect(self._on_jog_x_neg_pressed)
|
||||
self.ui.newscan_jog_x_neg_btn.released.connect(self._on_jog_stop)
|
||||
self.ui.newscan_jog_y_pos_btn.pressed.connect(self._on_jog_y_pos_pressed)
|
||||
self.ui.newscan_jog_y_pos_btn.released.connect(self._on_jog_stop)
|
||||
self.ui.newscan_jog_y_neg_btn.pressed.connect(self._on_jog_y_neg_pressed)
|
||||
self.ui.newscan_jog_y_neg_btn.released.connect(self._on_jog_stop)
|
||||
|
||||
# Continue scan page
|
||||
self.ui.continuescan_resume_scans.clicked.connect(self._on_resume_scan)
|
||||
|
||||
# Scan progress page
|
||||
self.ui.abort_scan_button.clicked.connect(self._on_abort_scan)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Navigation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _go_to_start(self):
|
||||
self.ui.stackedWidget.setCurrentIndex(PAGE_START)
|
||||
|
||||
def _go_to_options(self):
|
||||
self._populate_options_page()
|
||||
self.ui.stackedWidget.setCurrentIndex(PAGE_OPTIONS)
|
||||
|
||||
def _go_to_newscan(self):
|
||||
self._populate_newscan_page()
|
||||
self.ui.stackedWidget.setCurrentIndex(PAGE_NEWSCAN)
|
||||
|
||||
def _go_to_continuescan(self):
|
||||
self._populate_continuescan_page()
|
||||
self.ui.stackedWidget.setCurrentIndex(PAGE_CONTINUESCAN)
|
||||
|
||||
def _go_to_scan_progress(self):
|
||||
self.ui.stackedWidget.setCurrentIndex(PAGE_SCAN_PROGRESS)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Options page
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_serial_ports(self) -> list[str]:
|
||||
return sorted(p.device for p in serial.tools.list_ports.comports())
|
||||
|
||||
def _populate_combo(self, combo: QtWidgets.QComboBox, items: list[str], current: str):
|
||||
"""Refill a combo box, re-selecting `current` if present."""
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItems(items)
|
||||
idx = combo.findText(current)
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
elif current:
|
||||
combo.insertItem(0, current)
|
||||
combo.setCurrentIndex(0)
|
||||
combo.blockSignals(False)
|
||||
|
||||
def _populate_options_page(self):
|
||||
cfg = self.config
|
||||
ports = self._get_serial_ports()
|
||||
|
||||
# ---- Kinematics tab ----
|
||||
self.ui.scan_velocity_edit.setText(str(cfg["stage"]["scan_velocity_mm_s"]))
|
||||
self.ui.scan_accel_edit.setText(str(cfg["stage"]["scan_acceleration_mm_s2"]))
|
||||
self.ui.optical_axis_x_edit.setText(str(cfg["stage"]["optical_axis_x_mm"]))
|
||||
self.ui.optical_axis_y_edit.setText(str(cfg["stage"]["optical_axis_y_mm"]))
|
||||
self.ui.stage_serial_edit.setText(cfg["stage"]["serial_port"])
|
||||
self._populate_combo(self.ui.stage_trigger_combo, TRIGGER_OPTIONS, cfg["stage"]["trigger"])
|
||||
|
||||
# ---- Detection / VIS tab ----
|
||||
self.ui.detection_power_edit.setText(str(cfg["detection_laser"]["power_mw"]))
|
||||
|
||||
# ---- Generation / IR tab ----
|
||||
self._populate_combo(self.ui.comboBox, ports, cfg["generation_laser"]["serial_port"])
|
||||
self.ui.generation_pulse_freq_edit.setText(str(cfg["generation_laser"]["pulse_frequency_hz"]))
|
||||
self.ui.diode_pump_current_edit.setText(str(cfg["generation_laser"]["diode_pump_current_ma"]))
|
||||
|
||||
# ---- PulseDecimator tab ----
|
||||
self._populate_combo(self.ui.fpga_serial_port, ports, cfg["fpga"]["serial_port"])
|
||||
self.ui.fpga_divider_value_edit.setText(str(cfg["fpga"]["pulse_divider"]))
|
||||
self.ui.checkBox.setChecked(cfg["fpga"]["rowpack_enabled"])
|
||||
|
||||
# ---- T3R-SL tab ----
|
||||
self._populate_combo(self.ui.t3r_serial_port_edit, ports, cfg["t3r"]["serial_port"])
|
||||
self.ui.lineEdit.setText(str(cfg["t3r"]["t_axis_current_ma"]))
|
||||
self.ui.lineEdit_2.setText(str(cfg["t3r"]["gr_axis_current_ma"]))
|
||||
self._populate_combo(self.ui.comboBox_2, MICROSTEPPING_OPTIONS, cfg["t3r"]["t_axis_microstepping"])
|
||||
self._populate_combo(self.ui.comboBox_3, MICROSTEPPING_OPTIONS, cfg["t3r"]["gr_axis_microstepping"])
|
||||
|
||||
# ---- Oscilloscope tab ----
|
||||
self.ui.scope_ip_address_edit.setText(cfg["oscilloscope"]["ip_address"])
|
||||
|
||||
def _on_options_save(self):
|
||||
try:
|
||||
# Kinematics
|
||||
self.config["stage"]["scan_velocity_mm_s"] = float(self.ui.scan_velocity_edit.text())
|
||||
self.config["stage"]["scan_acceleration_mm_s2"] = float(self.ui.scan_accel_edit.text())
|
||||
self.config["stage"]["optical_axis_x_mm"] = float(self.ui.optical_axis_x_edit.text())
|
||||
self.config["stage"]["optical_axis_y_mm"] = float(self.ui.optical_axis_y_edit.text())
|
||||
self.config["stage"]["serial_port"] = self.ui.stage_serial_edit.text().strip()
|
||||
self.config["stage"]["trigger"] = self.ui.stage_trigger_combo.currentText()
|
||||
|
||||
# Detection / VIS
|
||||
self.config["detection_laser"]["power_mw"] = float(self.ui.detection_power_edit.text())
|
||||
|
||||
# Generation / IR
|
||||
self.config["generation_laser"]["serial_port"] = self.ui.comboBox.currentText()
|
||||
self.config["generation_laser"]["pulse_frequency_hz"] = int(self.ui.generation_pulse_freq_edit.text())
|
||||
self.config["generation_laser"]["diode_pump_current_ma"] = float(self.ui.diode_pump_current_edit.text())
|
||||
|
||||
# PulseDecimator
|
||||
self.config["fpga"]["serial_port"] = self.ui.fpga_serial_port.currentText()
|
||||
self.config["fpga"]["pulse_divider"] = int(self.ui.fpga_divider_value_edit.text())
|
||||
self.config["fpga"]["rowpack_enabled"] = self.ui.checkBox.isChecked()
|
||||
|
||||
# T3R-SL
|
||||
self.config["t3r"]["serial_port"] = self.ui.t3r_serial_port_edit.currentText()
|
||||
self.config["t3r"]["t_axis_current_ma"] = float(self.ui.lineEdit.text())
|
||||
self.config["t3r"]["gr_axis_current_ma"] = float(self.ui.lineEdit_2.text())
|
||||
self.config["t3r"]["t_axis_microstepping"] = self.ui.comboBox_2.currentText()
|
||||
self.config["t3r"]["gr_axis_microstepping"] = self.ui.comboBox_3.currentText()
|
||||
|
||||
# Oscilloscope
|
||||
self.config["oscilloscope"]["ip_address"] = self.ui.scope_ip_address_edit.text().strip()
|
||||
|
||||
except ValueError as e:
|
||||
QtWidgets.QMessageBox.warning(self, "Invalid input", str(e))
|
||||
return
|
||||
|
||||
self._save_config()
|
||||
self._go_to_start()
|
||||
|
||||
def _refresh_serial_ports_for_combos(self, *combos: QtWidgets.QComboBox):
|
||||
"""Re-populate serial port combos, preserving current selections."""
|
||||
ports = self._get_serial_ports()
|
||||
for combo in combos:
|
||||
self._populate_combo(combo, ports, combo.currentText())
|
||||
|
||||
def _on_refresh_serial_ports(self):
|
||||
self._refresh_serial_ports_for_combos(self.ui.comboBox)
|
||||
|
||||
def _on_fpga_refresh_ports(self):
|
||||
self._refresh_serial_ports_for_combos(self.ui.fpga_serial_port)
|
||||
|
||||
def _on_t3r_refresh_ports(self):
|
||||
self._refresh_serial_ports_for_combos(self.ui.t3r_serial_port_edit)
|
||||
|
||||
def _on_test_stage_connection(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_fpga_connect(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_scope_connect(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_generation_connect(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_detection_test(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_t3r_connect(self):
|
||||
pass # TODO
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# New scan page
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _populate_newscan_page(self):
|
||||
self.ui.newscan_save_directory_edit.setText(str(Path.home() / "scans"))
|
||||
|
||||
def _on_newscan_browse(self):
|
||||
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select save directory")
|
||||
if directory:
|
||||
self.ui.newscan_save_directory_edit.setText(directory)
|
||||
|
||||
def _on_newscan_set_start(self):
|
||||
pass # TODO: capture current stage position as scan start
|
||||
|
||||
def _on_newscan_get_delta(self):
|
||||
pass # TODO: capture current stage position as scan end (compute delta)
|
||||
|
||||
def _on_newscan_toggle_vis_laser(self):
|
||||
pass # TODO: toggle vis laser on/off
|
||||
|
||||
def _on_newscan_start_scan(self):
|
||||
scan_params = self._build_scan_params()
|
||||
if scan_params is None:
|
||||
return
|
||||
self._start_scan(scan_params)
|
||||
|
||||
def _build_scan_params(self) -> Optional[dict]:
|
||||
"""Read newscan page widgets and return scan parameter dict, or None on error."""
|
||||
try:
|
||||
x_start = float(self.ui.newscan_start_x_coord_edit.text())
|
||||
y_start = float(self.ui.newscan_start_y_coord_edit.text())
|
||||
x_delta = float(self.ui.newscan_delta_x_coord_edit.text())
|
||||
y_delta = float(self.ui.newscan_delta_y_coord_edit.text())
|
||||
except ValueError:
|
||||
QtWidgets.QMessageBox.warning(self, "Invalid input", "Scan coordinates must be numbers.")
|
||||
return None
|
||||
|
||||
pixel_size_map = {
|
||||
self.ui.newscan_50_micron_radio: 0.05,
|
||||
self.ui.newscan_100_micron_radio: 0.10,
|
||||
self.ui.newscan_250_micron_radio: 0.25,
|
||||
}
|
||||
row_spacing = next(
|
||||
(v for btn, v in pixel_size_map.items() if btn.isChecked()), 0.10
|
||||
)
|
||||
|
||||
return {
|
||||
"x_start_mm": x_start,
|
||||
"y_start_mm": y_start,
|
||||
"x_delta_mm": x_delta,
|
||||
"y_delta_mm": y_delta,
|
||||
"row_spacing_mm": row_spacing,
|
||||
"num_angles": int(self.ui.newscan_num_angles_combo.currentText()),
|
||||
"friendly_name": self.ui.newscan_friendly_name_edit.text(),
|
||||
"file_prefix": self.ui.newcsan_file_prefix_edit.text(),
|
||||
"save_directory": self.ui.newscan_save_directory_edit.text(),
|
||||
"scan_velocity_mm_s": self.config["stage"]["scan_velocity_mm_s"],
|
||||
"scan_acceleration_mm_s2": self.config["stage"]["scan_acceleration_mm_s2"],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Jog controls
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_jog_x_pos_pressed(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_jog_x_neg_pressed(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_jog_y_pos_pressed(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_jog_y_neg_pressed(self):
|
||||
pass # TODO
|
||||
|
||||
def _on_jog_stop(self):
|
||||
pass # TODO
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Continue scan page
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _populate_continuescan_page(self):
|
||||
pass # TODO: populate list of interrupted scans
|
||||
|
||||
def _on_resume_scan(self):
|
||||
pass # TODO: resume selected scan
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scan execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_scan(self, scan_params: dict):
|
||||
self.scan_thread = QtCore.QThread()
|
||||
self.scan_worker = ScanWorker(scan_params, self.motion_worker)
|
||||
self.scan_worker.moveToThread(self.scan_thread)
|
||||
|
||||
self.scan_thread.started.connect(self.scan_worker.run_scan)
|
||||
self.scan_worker.scan_started.connect(self._on_scan_started)
|
||||
self.scan_worker.scan_completed.connect(self._on_scan_completed)
|
||||
self.scan_worker.scan_failed.connect(self._on_scan_failed)
|
||||
self.scan_worker.current_progress.connect(self.ui.scanning_scan_progbar.setValue)
|
||||
self.scan_worker.overall_progress.connect(self.ui.scanning_overall_progbar.setValue)
|
||||
self.scan_worker.status_message.connect(self.ui.scanning_stage_state_label.setText)
|
||||
|
||||
self._go_to_scan_progress()
|
||||
self.scan_thread.start()
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def _on_scan_started(self):
|
||||
self.ui.abort_scan_button.setEnabled(True)
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def _on_scan_completed(self):
|
||||
self._cleanup_scan_thread()
|
||||
QtWidgets.QMessageBox.information(self, "Scan complete", "Scan finished successfully.")
|
||||
self._go_to_start()
|
||||
|
||||
@QtCore.pyqtSlot(str)
|
||||
def _on_scan_failed(self, error: str):
|
||||
self._cleanup_scan_thread()
|
||||
QtWidgets.QMessageBox.critical(self, "Scan failed", error)
|
||||
self._go_to_start()
|
||||
|
||||
def _on_abort_scan(self):
|
||||
if self.scan_worker:
|
||||
self.scan_worker.stop()
|
||||
|
||||
def _cleanup_scan_thread(self):
|
||||
if self.scan_thread:
|
||||
self.scan_thread.quit()
|
||||
self.scan_thread.wait()
|
||||
self.scan_thread = None
|
||||
self.scan_worker = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Genesis laser worker
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_genesis_worker(self):
|
||||
com_port = self.config.get("genesis_laser", {}).get("com_port", "/dev/ttyUSB0")
|
||||
self.genesis_worker = GenesisWorker(com_port)
|
||||
self.genesis_thread = QtCore.QThread()
|
||||
self.genesis_worker.moveToThread(self.genesis_thread)
|
||||
self.genesis_thread.started.connect(self.genesis_worker.run)
|
||||
self.genesis_thread.start()
|
||||
|
||||
def _cleanup_genesis_worker(self):
|
||||
if self.genesis_worker:
|
||||
self.genesis_worker.stop()
|
||||
if self.genesis_thread:
|
||||
self.genesis_thread.quit()
|
||||
self.genesis_thread.wait()
|
||||
self.genesis_worker = None
|
||||
self.genesis_thread = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._cleanup_genesis_worker()
|
||||
self._cleanup_scan_thread()
|
||||
if self.motion_thread:
|
||||
self.motion_thread.quit()
|
||||
self.motion_thread.wait()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def main():
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
qss_path = Path(__file__).parent / "app_style.qss"
|
||||
if qss_path.exists():
|
||||
app.setStyleSheet(qss_path.read_text())
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"t3r_port": "/dev/ttyACM0",
|
||||
"bbd_port": "/dev/ttyAPT",
|
||||
"oscope_ip": "192.168.100.105",
|
||||
"laser_freq_hz": 20000.0,
|
||||
"save_dir": "/opt/scanengine-3/scans"
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BBD202 Stage Controller Test Application
|
||||
PyQt6 GUI for jogging the stage and configuring trigger outputs.
|
||||
Thomas Ales | Mar 2026
|
||||
"""
|
||||
|
||||
import sys
|
||||
import queue
|
||||
import time
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QDoubleSpinBox,
|
||||
QStatusBar, QMessageBox, QGridLayout, QCheckBox, QFrame
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer
|
||||
from PyQt6.QtGui import QFont, QKeySequence, QShortcut
|
||||
|
||||
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y
|
||||
from hardware.pybbd202.apt_constants import TriggerBitsServo
|
||||
|
||||
|
||||
# ── Worker thread ─────────────────────────────────────────────────────────────
|
||||
|
||||
class StageCommand:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
self.cmd = cmd
|
||||
self.params = kwargs
|
||||
|
||||
|
||||
class StageWorker(QObject):
|
||||
connected = pyqtSignal()
|
||||
disconnected = pyqtSignal()
|
||||
conn_failed = pyqtSignal(str)
|
||||
position_updated = pyqtSignal(float, float) # x_mm, y_mm
|
||||
status_updated = pyqtSignal(bool, bool, bool, bool) # x_homed, y_homed, x_moving, y_moving
|
||||
trigger_read = pyqtSignal(int, int) # x_mode, y_mode
|
||||
error_occurred = pyqtSignal(str)
|
||||
home_done = pyqtSignal(str) # 'x', 'y', or 'both'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._driver = None
|
||||
self._queue = queue.Queue()
|
||||
self._running = True
|
||||
self._last_x = None
|
||||
self._last_y = None
|
||||
|
||||
def enqueue(self, cmd, **kwargs):
|
||||
self._queue.put(StageCommand(cmd, **kwargs))
|
||||
|
||||
def run(self):
|
||||
self._poll_timer = QTimer()
|
||||
self._poll_timer.setInterval(200)
|
||||
self._poll_timer.timeout.connect(self._poll_status)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
cmd = self._queue.get(timeout=0.05)
|
||||
self._dispatch(cmd)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def _dispatch(self, cmd):
|
||||
try:
|
||||
if cmd.cmd == 'connect':
|
||||
self._do_connect(cmd.params['port'])
|
||||
elif cmd.cmd == 'disconnect':
|
||||
self._do_disconnect()
|
||||
elif cmd.cmd == 'home_x':
|
||||
self._do_home(AXIS_X, 'x')
|
||||
elif cmd.cmd == 'home_y':
|
||||
self._do_home(AXIS_Y, 'y')
|
||||
elif cmd.cmd == 'jog':
|
||||
self._do_jog(cmd.params['axis'], cmd.params['distance_mm'])
|
||||
elif cmd.cmd == 'move_abs':
|
||||
self._do_move_abs(cmd.params['axis'], cmd.params['pos_mm'])
|
||||
elif cmd.cmd == 'set_velocity':
|
||||
self._do_set_velocity(cmd.params['axis'],
|
||||
cmd.params['vel'], cmd.params['accel'])
|
||||
elif cmd.cmd == 'set_trigger':
|
||||
self._do_set_trigger(cmd.params['axis'], cmd.params['mode'])
|
||||
elif cmd.cmd == 'get_trigger':
|
||||
self._do_get_trigger()
|
||||
elif cmd.cmd == 'stop':
|
||||
pass # TODO: add stop message if needed
|
||||
except TimeoutError as e:
|
||||
self.error_occurred.emit(f"Timeout: {e}")
|
||||
except ValueError as e:
|
||||
self.error_occurred.emit(f"Value error: {e}")
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(f"Error: {e}")
|
||||
|
||||
def _do_connect(self, port):
|
||||
try:
|
||||
self._driver = ThorlabsServoDriver()
|
||||
self._driver.connect(port=port)
|
||||
self._driver.enable_axis(AXIS_X)
|
||||
self._driver.enable_axis(AXIS_Y)
|
||||
self._driver.start_polling(interval=0.2)
|
||||
time.sleep(0.5) # let first polls come in
|
||||
self.connected.emit()
|
||||
except Exception as e:
|
||||
self._driver = None
|
||||
self.conn_failed.emit(str(e))
|
||||
|
||||
def _do_disconnect(self):
|
||||
if self._driver:
|
||||
try:
|
||||
self._driver.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._driver = None
|
||||
self.disconnected.emit()
|
||||
|
||||
def _do_home(self, axis, label):
|
||||
self._driver.home_axis(axis, timeout=60.0)
|
||||
self.home_done.emit(label)
|
||||
|
||||
def _do_jog(self, axis, distance_mm):
|
||||
self._driver.move_axis_relative(axis, distance_mm)
|
||||
|
||||
def _do_move_abs(self, axis, pos_mm):
|
||||
self._driver.move_axis_absolute(axis, pos_mm)
|
||||
|
||||
def _do_set_velocity(self, axis, vel, accel):
|
||||
self._driver.set_velocity_params(axis, max_velocity=vel, acceleration=accel)
|
||||
|
||||
def _do_set_trigger(self, axis, mode):
|
||||
self._driver.set_trigger(axis, mode)
|
||||
|
||||
def _do_get_trigger(self):
|
||||
x_mode = int(self._driver.get_trigger(AXIS_X))
|
||||
y_mode = int(self._driver.get_trigger(AXIS_Y))
|
||||
self.trigger_read.emit(x_mode, y_mode)
|
||||
|
||||
def _poll_status(self):
|
||||
if not self._driver:
|
||||
return
|
||||
x = self._driver.positions[0]
|
||||
y = self._driver.positions[1]
|
||||
if x != self._last_x or y != self._last_y:
|
||||
self._last_x = x
|
||||
self._last_y = y
|
||||
self.position_updated.emit(x, y)
|
||||
self.status_updated.emit(
|
||||
self._driver.am_homed[0], self._driver.am_homed[1],
|
||||
self._driver.am_moving[0], self._driver.am_moving[1]
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
|
||||
# ── Main window ───────────────────────────────────────────────────────────────
|
||||
|
||||
TRIG_OPTIONS = [
|
||||
("Disabled", 0x00),
|
||||
("In: Logic High", TriggerBitsServo.TRIGIN_HIGH),
|
||||
("In: Relative Move", TriggerBitsServo.TRIGIN_RELMOVE),
|
||||
("In: Absolute Move", TriggerBitsServo.TRIGIN_ABSMOVE),
|
||||
("In: Home Move", TriggerBitsServo.TRIGIN_HOMEMOVE),
|
||||
("Out: Logic High", TriggerBitsServo.TRIGOUT_HIGH),
|
||||
("Out: In Motion", TriggerBitsServo.TRIGOUT_INMOTION),
|
||||
("Out: Motion Complete", TriggerBitsServo.TRIGOUT_MOTIONCOMPLETE),
|
||||
("Out: At Max Velocity", TriggerBitsServo.TRIGOUT_MAXVELOCITY),
|
||||
("Out: High + Max Vel", TriggerBitsServo.TRIGOUT_MAXV),
|
||||
]
|
||||
|
||||
|
||||
class BBD202TestApp(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("BBD202 Stage Controller Test")
|
||||
self.resize(700, 620)
|
||||
|
||||
self._worker = StageWorker()
|
||||
self._thread = QThread()
|
||||
self._worker.moveToThread(self._thread)
|
||||
self._thread.started.connect(self._worker.run)
|
||||
|
||||
self._worker.connected.connect(self._on_connected)
|
||||
self._worker.disconnected.connect(self._on_disconnected)
|
||||
self._worker.conn_failed.connect(self._on_conn_failed)
|
||||
self._worker.position_updated.connect(self._on_position_updated)
|
||||
self._worker.status_updated.connect(self._on_status_updated)
|
||||
self._worker.trigger_read.connect(self._on_trigger_read)
|
||||
self._worker.error_occurred.connect(self._on_error)
|
||||
self._worker.home_done.connect(self._on_home_done)
|
||||
|
||||
self._thread.start()
|
||||
|
||||
# Periodic status poll from worker — drive via a QTimer in main thread
|
||||
self._status_timer = QTimer()
|
||||
self._status_timer.setInterval(200)
|
||||
self._status_timer.timeout.connect(self._poll_worker_status)
|
||||
|
||||
self._build_ui()
|
||||
self._set_connected(False)
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root = QVBoxLayout(central)
|
||||
root.setSpacing(8)
|
||||
|
||||
root.addWidget(self._build_connection_group())
|
||||
root.addWidget(self._build_status_group())
|
||||
root.addWidget(self._build_jog_group())
|
||||
root.addWidget(self._build_velocity_group())
|
||||
root.addWidget(self._build_trigger_group())
|
||||
|
||||
self.statusbar = QStatusBar()
|
||||
self.setStatusBar(self.statusbar)
|
||||
self.statusbar.showMessage("Not connected.")
|
||||
|
||||
def _build_connection_group(self):
|
||||
grp = QGroupBox("Connection")
|
||||
lay = QHBoxLayout(grp)
|
||||
|
||||
lay.addWidget(QLabel("Serial Port:"))
|
||||
self.le_port = QLineEdit("/dev/ttyUSB1")
|
||||
self.le_port.setMaximumWidth(150)
|
||||
lay.addWidget(self.le_port)
|
||||
|
||||
self.btn_connect = QPushButton("Connect")
|
||||
self.btn_connect.clicked.connect(self._on_connect_clicked)
|
||||
lay.addWidget(self.btn_connect)
|
||||
|
||||
self.btn_disconnect = QPushButton("Disconnect")
|
||||
self.btn_disconnect.clicked.connect(self._on_disconnect_clicked)
|
||||
lay.addWidget(self.btn_disconnect)
|
||||
|
||||
lay.addStretch()
|
||||
return grp
|
||||
|
||||
def _build_status_group(self):
|
||||
grp = QGroupBox("Status")
|
||||
grid = QGridLayout(grp)
|
||||
|
||||
bold = QFont()
|
||||
bold.setBold(True)
|
||||
|
||||
grid.addWidget(QLabel(""), 0, 0)
|
||||
lbl_x = QLabel("X"); lbl_x.setFont(bold)
|
||||
lbl_y = QLabel("Y"); lbl_y.setFont(bold)
|
||||
grid.addWidget(lbl_x, 0, 1, Qt.AlignmentFlag.AlignCenter)
|
||||
grid.addWidget(lbl_y, 0, 2, Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
grid.addWidget(QLabel("Position (mm):"), 1, 0)
|
||||
self.lbl_x_pos = QLabel("---")
|
||||
self.lbl_y_pos = QLabel("---")
|
||||
grid.addWidget(self.lbl_x_pos, 1, 1, Qt.AlignmentFlag.AlignCenter)
|
||||
grid.addWidget(self.lbl_y_pos, 1, 2, Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
grid.addWidget(QLabel("Homed:"), 2, 0)
|
||||
self.lbl_x_homed = QLabel("No")
|
||||
self.lbl_y_homed = QLabel("No")
|
||||
grid.addWidget(self.lbl_x_homed, 2, 1, Qt.AlignmentFlag.AlignCenter)
|
||||
grid.addWidget(self.lbl_y_homed, 2, 2, Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
grid.addWidget(QLabel("Moving:"), 3, 0)
|
||||
self.lbl_x_moving = QLabel("No")
|
||||
self.lbl_y_moving = QLabel("No")
|
||||
grid.addWidget(self.lbl_x_moving, 3, 1, Qt.AlignmentFlag.AlignCenter)
|
||||
grid.addWidget(self.lbl_y_moving, 3, 2, Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Home buttons
|
||||
self.btn_home_x = QPushButton("Home X")
|
||||
self.btn_home_y = QPushButton("Home Y")
|
||||
self.btn_home_x.clicked.connect(lambda: self._worker.enqueue('home_x'))
|
||||
self.btn_home_y.clicked.connect(lambda: self._worker.enqueue('home_y'))
|
||||
grid.addWidget(self.btn_home_x, 4, 1)
|
||||
grid.addWidget(self.btn_home_y, 4, 2)
|
||||
|
||||
return grp
|
||||
|
||||
def _build_jog_group(self):
|
||||
grp = QGroupBox("Jog / Manual Move")
|
||||
lay = QVBoxLayout(grp)
|
||||
|
||||
# Step size
|
||||
step_row = QHBoxLayout()
|
||||
step_row.addWidget(QLabel("Step size (mm):"))
|
||||
self.dsb_step = QDoubleSpinBox()
|
||||
self.dsb_step.setRange(0.001, 50.0)
|
||||
self.dsb_step.setValue(1.0)
|
||||
self.dsb_step.setDecimals(3)
|
||||
self.dsb_step.setSingleStep(0.5)
|
||||
self.dsb_step.setMaximumWidth(100)
|
||||
step_row.addWidget(self.dsb_step)
|
||||
step_row.addStretch()
|
||||
lay.addLayout(step_row)
|
||||
|
||||
# Jog buttons — arrow-style grid
|
||||
jog_grid = QGridLayout()
|
||||
jog_grid.setSpacing(4)
|
||||
|
||||
self.btn_y_pos = QPushButton("Y +")
|
||||
self.btn_y_neg = QPushButton("Y −")
|
||||
self.btn_x_neg = QPushButton("← X −")
|
||||
self.btn_x_pos = QPushButton("X + →")
|
||||
|
||||
for btn in (self.btn_y_pos, self.btn_y_neg,
|
||||
self.btn_x_neg, self.btn_x_pos):
|
||||
btn.setMinimumWidth(80)
|
||||
|
||||
jog_grid.addWidget(self.btn_y_pos, 0, 1)
|
||||
jog_grid.addWidget(self.btn_x_neg, 1, 0)
|
||||
jog_grid.addWidget(self.btn_x_pos, 1, 2)
|
||||
jog_grid.addWidget(self.btn_y_neg, 2, 1)
|
||||
|
||||
self.btn_y_pos.clicked.connect(
|
||||
lambda: self._worker.enqueue('jog', axis=AXIS_Y,
|
||||
distance_mm=self.dsb_step.value()))
|
||||
self.btn_y_neg.clicked.connect(
|
||||
lambda: self._worker.enqueue('jog', axis=AXIS_Y,
|
||||
distance_mm=-self.dsb_step.value()))
|
||||
self.btn_x_pos.clicked.connect(
|
||||
lambda: self._worker.enqueue('jog', axis=AXIS_X,
|
||||
distance_mm=self.dsb_step.value()))
|
||||
self.btn_x_neg.clicked.connect(
|
||||
lambda: self._worker.enqueue('jog', axis=AXIS_X,
|
||||
distance_mm=-self.dsb_step.value()))
|
||||
|
||||
lay.addLayout(jog_grid)
|
||||
|
||||
# Absolute move row
|
||||
abs_row = QHBoxLayout()
|
||||
abs_row.addWidget(QLabel("Go to X (mm):"))
|
||||
self.dsb_abs_x = QDoubleSpinBox()
|
||||
self.dsb_abs_x.setRange(0.0, 110.0)
|
||||
self.dsb_abs_x.setDecimals(3)
|
||||
self.dsb_abs_x.setMaximumWidth(100)
|
||||
abs_row.addWidget(self.dsb_abs_x)
|
||||
|
||||
abs_row.addWidget(QLabel("Y (mm):"))
|
||||
self.dsb_abs_y = QDoubleSpinBox()
|
||||
self.dsb_abs_y.setRange(0.0, 75.0)
|
||||
self.dsb_abs_y.setDecimals(3)
|
||||
self.dsb_abs_y.setMaximumWidth(100)
|
||||
abs_row.addWidget(self.dsb_abs_y)
|
||||
|
||||
btn_go = QPushButton("Move")
|
||||
btn_go.clicked.connect(self._on_abs_move_clicked)
|
||||
abs_row.addWidget(btn_go)
|
||||
abs_row.addStretch()
|
||||
lay.addLayout(abs_row)
|
||||
|
||||
return grp
|
||||
|
||||
def _build_velocity_group(self):
|
||||
grp = QGroupBox("Velocity Parameters")
|
||||
lay = QHBoxLayout(grp)
|
||||
|
||||
lay.addWidget(QLabel("Max Vel (mm/s):"))
|
||||
self.dsb_vel = QDoubleSpinBox()
|
||||
self.dsb_vel.setRange(0.1, 300.0)
|
||||
self.dsb_vel.setValue(20.0)
|
||||
self.dsb_vel.setDecimals(1)
|
||||
self.dsb_vel.setMaximumWidth(90)
|
||||
lay.addWidget(self.dsb_vel)
|
||||
|
||||
lay.addWidget(QLabel("Accel (mm/s²):"))
|
||||
self.dsb_accel = QDoubleSpinBox()
|
||||
self.dsb_accel.setRange(1.0, 2000.0)
|
||||
self.dsb_accel.setValue(100.0)
|
||||
self.dsb_accel.setDecimals(1)
|
||||
self.dsb_accel.setMaximumWidth(90)
|
||||
lay.addWidget(self.dsb_accel)
|
||||
|
||||
lay.addWidget(QLabel("Axis:"))
|
||||
self.cmb_vel_axis = QComboBox()
|
||||
self.cmb_vel_axis.addItems(["X", "Y"])
|
||||
lay.addWidget(self.cmb_vel_axis)
|
||||
|
||||
btn_set_vel = QPushButton("Apply")
|
||||
btn_set_vel.clicked.connect(self._on_set_velocity_clicked)
|
||||
lay.addWidget(btn_set_vel)
|
||||
|
||||
btn_read_vel = QPushButton("Read")
|
||||
btn_read_vel.clicked.connect(self._on_read_velocity_clicked)
|
||||
lay.addWidget(btn_read_vel)
|
||||
|
||||
lay.addStretch()
|
||||
return grp
|
||||
|
||||
def _build_trigger_group(self):
|
||||
grp = QGroupBox("Trigger Configuration")
|
||||
lay = QVBoxLayout(grp)
|
||||
|
||||
grid = QGridLayout()
|
||||
bold = QFont(); bold.setBold(True)
|
||||
|
||||
lbl_x = QLabel("X Axis"); lbl_x.setFont(bold)
|
||||
lbl_y = QLabel("Y Axis"); lbl_y.setFont(bold)
|
||||
grid.addWidget(lbl_x, 0, 1, Qt.AlignmentFlag.AlignCenter)
|
||||
grid.addWidget(lbl_y, 0, 2, Qt.AlignmentFlag.AlignCenter)
|
||||
grid.addWidget(QLabel("Trigger Mode:"), 1, 0)
|
||||
|
||||
self.cmb_trig_x = QComboBox()
|
||||
self.cmb_trig_y = QComboBox()
|
||||
for name, _ in TRIG_OPTIONS:
|
||||
self.cmb_trig_x.addItem(name)
|
||||
self.cmb_trig_y.addItem(name)
|
||||
grid.addWidget(self.cmb_trig_x, 1, 1)
|
||||
grid.addWidget(self.cmb_trig_y, 1, 2)
|
||||
|
||||
lay.addLayout(grid)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_apply = QPushButton("Apply Trigger Settings")
|
||||
btn_apply.clicked.connect(self._on_apply_trigger_clicked)
|
||||
btn_row.addWidget(btn_apply)
|
||||
|
||||
btn_read = QPushButton("Read from Controller")
|
||||
btn_read.clicked.connect(lambda: self._worker.enqueue('get_trigger'))
|
||||
btn_row.addWidget(btn_read)
|
||||
btn_row.addStretch()
|
||||
lay.addLayout(btn_row)
|
||||
|
||||
return grp
|
||||
|
||||
# ── UI state helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _set_connected(self, connected):
|
||||
self.btn_connect.setEnabled(not connected)
|
||||
self.btn_disconnect.setEnabled(connected)
|
||||
self.le_port.setEnabled(not connected)
|
||||
|
||||
for w in (self.btn_home_x, self.btn_home_y,
|
||||
self.btn_x_pos, self.btn_x_neg,
|
||||
self.btn_y_pos, self.btn_y_neg,
|
||||
self.dsb_step, self.dsb_abs_x, self.dsb_abs_y,
|
||||
self.dsb_vel, self.dsb_accel,
|
||||
self.cmb_vel_axis, self.cmb_trig_x, self.cmb_trig_y):
|
||||
w.setEnabled(connected)
|
||||
|
||||
# The Apply / Read / Move buttons — find them by iterating children
|
||||
for btn in self.findChildren(QPushButton):
|
||||
if btn not in (self.btn_connect, self.btn_disconnect):
|
||||
btn.setEnabled(connected)
|
||||
|
||||
# Keep connect/disconnect right
|
||||
self.btn_connect.setEnabled(not connected)
|
||||
self.btn_disconnect.setEnabled(connected)
|
||||
|
||||
def _poll_worker_status(self):
|
||||
"""Drive the worker's status poll from main thread timer."""
|
||||
if self._worker._driver:
|
||||
self._worker._poll_status()
|
||||
|
||||
# ── Slots ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_connect_clicked(self):
|
||||
port = self.le_port.text().strip()
|
||||
if not port:
|
||||
QMessageBox.warning(self, "Input Error", "Enter a serial port.")
|
||||
return
|
||||
self.btn_connect.setEnabled(False)
|
||||
self.statusbar.showMessage(f"Connecting to {port}…")
|
||||
self._worker.enqueue('connect', port=port)
|
||||
|
||||
def _on_disconnect_clicked(self):
|
||||
self._status_timer.stop()
|
||||
self._worker.enqueue('disconnect')
|
||||
|
||||
def _on_connected(self):
|
||||
self._set_connected(True)
|
||||
self._status_timer.start()
|
||||
self.statusbar.showMessage("Connected.")
|
||||
|
||||
def _on_disconnected(self):
|
||||
self._set_connected(False)
|
||||
self._status_timer.stop()
|
||||
self.lbl_x_pos.setText("---")
|
||||
self.lbl_y_pos.setText("---")
|
||||
self.lbl_x_homed.setText("No")
|
||||
self.lbl_y_homed.setText("No")
|
||||
self.lbl_x_moving.setText("No")
|
||||
self.lbl_y_moving.setText("No")
|
||||
self.statusbar.showMessage("Disconnected.")
|
||||
|
||||
def _on_conn_failed(self, msg):
|
||||
self._set_connected(False)
|
||||
self.statusbar.showMessage(f"Connection failed: {msg}")
|
||||
QMessageBox.critical(self, "Connection Failed", msg)
|
||||
|
||||
def _on_position_updated(self, x, y):
|
||||
self.lbl_x_pos.setText(f"{x:.3f}")
|
||||
self.lbl_y_pos.setText(f"{y:.3f}")
|
||||
|
||||
def _on_status_updated(self, x_homed, y_homed, x_moving, y_moving):
|
||||
self.lbl_x_homed.setText("Yes" if x_homed else "No")
|
||||
self.lbl_y_homed.setText("Yes" if y_homed else "No")
|
||||
self.lbl_x_moving.setText("Yes" if x_moving else "No")
|
||||
self.lbl_y_moving.setText("Yes" if y_moving else "No")
|
||||
|
||||
def _on_home_done(self, axis):
|
||||
self.statusbar.showMessage(f"{axis.upper()} homing complete.")
|
||||
|
||||
def _on_abs_move_clicked(self):
|
||||
self._worker.enqueue('move_abs', axis=AXIS_X,
|
||||
pos_mm=self.dsb_abs_x.value())
|
||||
self._worker.enqueue('move_abs', axis=AXIS_Y,
|
||||
pos_mm=self.dsb_abs_y.value())
|
||||
|
||||
def _on_set_velocity_clicked(self):
|
||||
axis = AXIS_X if self.cmb_vel_axis.currentText() == "X" else AXIS_Y
|
||||
self._worker.enqueue('set_velocity', axis=axis,
|
||||
vel=self.dsb_vel.value(),
|
||||
accel=self.dsb_accel.value())
|
||||
self.statusbar.showMessage("Velocity parameters applied.")
|
||||
|
||||
def _on_read_velocity_clicked(self):
|
||||
axis = AXIS_X if self.cmb_vel_axis.currentText() == "X" else AXIS_Y
|
||||
if not self._worker._driver:
|
||||
return
|
||||
try:
|
||||
params = self._worker._driver.get_velocity_params(axis)
|
||||
self.dsb_vel.setValue(params['max_velocity'])
|
||||
self.dsb_accel.setValue(params['acceleration'])
|
||||
self.statusbar.showMessage(
|
||||
f"Read: vel={params['max_velocity']:.1f} mm/s, "
|
||||
f"accel={params['acceleration']:.1f} mm/s²")
|
||||
except Exception as e:
|
||||
self._on_error(str(e))
|
||||
|
||||
def _on_apply_trigger_clicked(self):
|
||||
x_mode = TRIG_OPTIONS[self.cmb_trig_x.currentIndex()][1]
|
||||
y_mode = TRIG_OPTIONS[self.cmb_trig_y.currentIndex()][1]
|
||||
self._worker.enqueue('set_trigger', axis=AXIS_X, mode=int(x_mode))
|
||||
self._worker.enqueue('set_trigger', axis=AXIS_Y, mode=int(y_mode))
|
||||
self.statusbar.showMessage("Trigger settings applied.")
|
||||
|
||||
def _on_trigger_read(self, x_mode, y_mode):
|
||||
def _find_idx(mode_val):
|
||||
for i, (_, v) in enumerate(TRIG_OPTIONS):
|
||||
if int(v) == mode_val:
|
||||
return i
|
||||
return 0
|
||||
|
||||
self.cmb_trig_x.setCurrentIndex(_find_idx(x_mode))
|
||||
self.cmb_trig_y.setCurrentIndex(_find_idx(y_mode))
|
||||
self.statusbar.showMessage(
|
||||
f"Trigger read: X=0x{x_mode:02X}, Y=0x{y_mode:02X}")
|
||||
|
||||
def _on_error(self, msg):
|
||||
self.statusbar.showMessage(f"Error: {msg}")
|
||||
QMessageBox.warning(self, "Error", msg)
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._status_timer.stop()
|
||||
if self._worker._driver:
|
||||
self._worker._do_disconnect()
|
||||
self._worker.stop()
|
||||
self._thread.quit()
|
||||
self._thread.wait(3000)
|
||||
event.accept()
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle('Fusion')
|
||||
win = BBD202TestApp()
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
UC480 Camera Test Application
|
||||
Simple PyQt6 GUI for testing and viewing the uC480/uEye camera.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QGroupBox, QLabel, QPushButton, QDoubleSpinBox, QSpinBox,
|
||||
QStatusBar, QSizePolicy
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QPixmap, QImage
|
||||
|
||||
from hardware.uc480_camera import UC480Camera, CameraStreamThread
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CameraTestWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("UC480 Camera Test")
|
||||
self.resize(900, 700)
|
||||
|
||||
self.camera: UC480Camera | None = None
|
||||
self.stream_thread: CameraStreamThread | None = None
|
||||
|
||||
self._build_ui()
|
||||
self._update_controls_enabled()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UI construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root = QHBoxLayout(central)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
# Left: video display
|
||||
self.lbl_image = QLabel("No camera connected")
|
||||
self.lbl_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.lbl_image.setMinimumSize(640, 480)
|
||||
self.lbl_image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.lbl_image.setStyleSheet("background: #111; color: #888; border: 1px solid #444;")
|
||||
root.addWidget(self.lbl_image, stretch=1)
|
||||
|
||||
# Right: controls panel
|
||||
panel = QWidget()
|
||||
panel.setFixedWidth(230)
|
||||
panel_layout = QVBoxLayout(panel)
|
||||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root.addWidget(panel)
|
||||
|
||||
# Connection group
|
||||
grp_conn = QGroupBox("Connection")
|
||||
conn_layout = QVBoxLayout(grp_conn)
|
||||
self.btn_connect = QPushButton("Connect")
|
||||
self.btn_connect.clicked.connect(self._on_connect)
|
||||
self.btn_disconnect = QPushButton("Disconnect")
|
||||
self.btn_disconnect.clicked.connect(self._on_disconnect)
|
||||
conn_layout.addWidget(self.btn_connect)
|
||||
conn_layout.addWidget(self.btn_disconnect)
|
||||
panel_layout.addWidget(grp_conn)
|
||||
|
||||
# Sensor info group
|
||||
grp_info = QGroupBox("Sensor Info")
|
||||
info_layout = QVBoxLayout(grp_info)
|
||||
self.lbl_sensor_name = QLabel("Name: —")
|
||||
self.lbl_resolution = QLabel("Resolution: —")
|
||||
self.lbl_pixel_size = QLabel("Pixel size: —")
|
||||
for lbl in (self.lbl_sensor_name, self.lbl_resolution, self.lbl_pixel_size):
|
||||
lbl.setWordWrap(True)
|
||||
info_layout.addWidget(lbl)
|
||||
panel_layout.addWidget(grp_info)
|
||||
|
||||
# Exposure group
|
||||
grp_exp = QGroupBox("Exposure (ms)")
|
||||
exp_layout = QHBoxLayout(grp_exp)
|
||||
self.spin_exposure = QDoubleSpinBox()
|
||||
self.spin_exposure.setRange(0.01, 10000.0)
|
||||
self.spin_exposure.setDecimals(2)
|
||||
self.spin_exposure.setSingleStep(1.0)
|
||||
self.spin_exposure.setValue(10.0)
|
||||
self.btn_set_exposure = QPushButton("Set")
|
||||
self.btn_set_exposure.setFixedWidth(40)
|
||||
self.btn_set_exposure.clicked.connect(self._on_set_exposure)
|
||||
exp_layout.addWidget(self.spin_exposure)
|
||||
exp_layout.addWidget(self.btn_set_exposure)
|
||||
panel_layout.addWidget(grp_exp)
|
||||
|
||||
# Gain group
|
||||
grp_gain = QGroupBox("Master Gain (0–100)")
|
||||
gain_layout = QHBoxLayout(grp_gain)
|
||||
self.spin_gain = QSpinBox()
|
||||
self.spin_gain.setRange(0, 100)
|
||||
self.spin_gain.setValue(0)
|
||||
self.btn_set_gain = QPushButton("Set")
|
||||
self.btn_set_gain.setFixedWidth(40)
|
||||
self.btn_set_gain.clicked.connect(self._on_set_gain)
|
||||
gain_layout.addWidget(self.spin_gain)
|
||||
gain_layout.addWidget(self.btn_set_gain)
|
||||
panel_layout.addWidget(grp_gain)
|
||||
|
||||
# Stream control group
|
||||
grp_stream = QGroupBox("Stream")
|
||||
stream_layout = QVBoxLayout(grp_stream)
|
||||
self.btn_start_stream = QPushButton("Start Stream")
|
||||
self.btn_start_stream.clicked.connect(self._on_start_stream)
|
||||
self.btn_stop_stream = QPushButton("Stop Stream")
|
||||
self.btn_stop_stream.clicked.connect(self._on_stop_stream)
|
||||
stream_layout.addWidget(self.btn_start_stream)
|
||||
stream_layout.addWidget(self.btn_stop_stream)
|
||||
panel_layout.addWidget(grp_stream)
|
||||
|
||||
panel_layout.addStretch()
|
||||
|
||||
# Status bar
|
||||
self.statusBar().showMessage("Not connected")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Button handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_connect(self):
|
||||
if self.camera is not None:
|
||||
self.statusBar().showMessage("Already connected")
|
||||
return
|
||||
|
||||
self.camera = UC480Camera(camera_id=1)
|
||||
self.camera.error_occurred.connect(self._on_camera_error)
|
||||
|
||||
if not self.camera.initialize():
|
||||
self.statusBar().showMessage("Failed to initialize camera")
|
||||
self.camera = None
|
||||
return
|
||||
|
||||
info = self.camera.get_sensor_info()
|
||||
self.lbl_sensor_name.setText(f"Name: {info.get('sensor_name', '?')}")
|
||||
self.lbl_resolution.setText(
|
||||
f"Resolution: {info.get('max_width', '?')}×{info.get('max_height', '?')}"
|
||||
)
|
||||
self.lbl_pixel_size.setText(f"Pixel size: {info.get('pixel_size', '?')} µm")
|
||||
|
||||
self.statusBar().showMessage("Camera connected")
|
||||
self._update_controls_enabled()
|
||||
|
||||
def _on_disconnect(self):
|
||||
self._on_stop_stream()
|
||||
if self.camera is not None:
|
||||
self.camera.cleanup()
|
||||
self.camera = None
|
||||
self.lbl_image.setText("No camera connected")
|
||||
self.lbl_sensor_name.setText("Name: —")
|
||||
self.lbl_resolution.setText("Resolution: —")
|
||||
self.lbl_pixel_size.setText("Pixel size: —")
|
||||
self.statusBar().showMessage("Disconnected")
|
||||
self._update_controls_enabled()
|
||||
|
||||
def _on_set_exposure(self):
|
||||
if self.camera is None:
|
||||
return
|
||||
val = self.spin_exposure.value()
|
||||
if self.camera.set_exposure(val):
|
||||
actual = self.camera.get_exposure()
|
||||
shown = f"{actual:.2f}" if actual is not None else f"{val:.2f}"
|
||||
self.statusBar().showMessage(f"Exposure set to {shown} ms")
|
||||
else:
|
||||
self.statusBar().showMessage("Failed to set exposure")
|
||||
|
||||
def _on_set_gain(self):
|
||||
if self.camera is None:
|
||||
return
|
||||
val = self.spin_gain.value()
|
||||
if self.camera.set_gain(val):
|
||||
self.statusBar().showMessage(f"Gain set to {val}")
|
||||
else:
|
||||
self.statusBar().showMessage("Failed to set gain")
|
||||
|
||||
def _on_start_stream(self):
|
||||
if self.camera is None or self.stream_thread is not None:
|
||||
return
|
||||
self.stream_thread = CameraStreamThread(self.camera)
|
||||
self.stream_thread.frame_ready.connect(self._on_frame)
|
||||
self.stream_thread.error_occurred.connect(self._on_camera_error)
|
||||
self.stream_thread.start()
|
||||
self.statusBar().showMessage("Streaming…")
|
||||
self._update_controls_enabled()
|
||||
|
||||
def _on_stop_stream(self):
|
||||
if self.stream_thread is not None:
|
||||
self.stream_thread.stop()
|
||||
self.stream_thread = None
|
||||
self.statusBar().showMessage("Stream stopped")
|
||||
self._update_controls_enabled()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Slots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_frame(self, image: QImage):
|
||||
scaled = image.scaled(
|
||||
self.lbl_image.width(),
|
||||
self.lbl_image.height(),
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.FastTransformation,
|
||||
)
|
||||
self.lbl_image.setPixmap(QPixmap.fromImage(scaled))
|
||||
|
||||
def _on_camera_error(self, msg: str):
|
||||
self.statusBar().showMessage(f"Error: {msg}")
|
||||
logger.error(msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_controls_enabled(self):
|
||||
connected = self.camera is not None
|
||||
streaming = self.stream_thread is not None
|
||||
|
||||
self.btn_connect.setEnabled(not connected)
|
||||
self.btn_disconnect.setEnabled(connected)
|
||||
self.btn_set_exposure.setEnabled(connected)
|
||||
self.spin_exposure.setEnabled(connected)
|
||||
self.btn_set_gain.setEnabled(connected)
|
||||
self.spin_gain.setEnabled(connected)
|
||||
self.btn_start_stream.setEnabled(connected and not streaming)
|
||||
self.btn_stop_stream.setEnabled(streaming)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._on_disconnect()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
window = CameraTestWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+4
-1
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"genesis_laser": {
|
||||
"com_port": "/dev/ttyUSB0"
|
||||
},
|
||||
"detection_laser": {
|
||||
"scan_power_mw": "125"
|
||||
},
|
||||
"generation_laser": {
|
||||
"com_port": "/dev/ttyUSB0",
|
||||
"com_port": "/dev/ttyACM0",
|
||||
"frequency_hz": "20000",
|
||||
"pump_diode_current_ma": "750",
|
||||
"focusing_frequency_hz": "20000",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Genesis Laser Worker Thread
|
||||
|
||||
Manages Genesis laser connection in a separate thread to keep the UI responsive.
|
||||
Provides async querying and status monitoring via Qt signals.
|
||||
"""
|
||||
|
||||
from PyQt6 import QtCore
|
||||
from hardware.genesis_core import SerialComm, I2CProtocol, I2CDevices, LaserControl
|
||||
import queue
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class GenesisCommand:
|
||||
"""Represents a genesis laser command"""
|
||||
def __init__(self, cmd_type: str, **kwargs):
|
||||
self.cmd_type = cmd_type
|
||||
self.params = kwargs
|
||||
|
||||
|
||||
class GenesisWorker(QtCore.QObject):
|
||||
"""
|
||||
Worker object for handling Genesis laser control in a separate thread.
|
||||
|
||||
Signals:
|
||||
connected: Emitted when laser connects successfully
|
||||
disconnected: Emitted when laser disconnects
|
||||
connection_failed: Emitted when connection fails (error_msg: str)
|
||||
laser_info_updated: Emitted with laser status
|
||||
query_completed: Emitted when a query operation completes (result: dict)
|
||||
error_occurred: Emitted when an error occurs (error_msg: str)
|
||||
"""
|
||||
|
||||
# Signals
|
||||
connected = QtCore.pyqtSignal()
|
||||
disconnected = QtCore.pyqtSignal()
|
||||
connection_failed = QtCore.pyqtSignal(str)
|
||||
laser_info_updated = QtCore.pyqtSignal(dict) # Status information
|
||||
query_completed = QtCore.pyqtSignal(dict) # Query result
|
||||
error_occurred = QtCore.pyqtSignal(str) # Error message
|
||||
|
||||
def __init__(self, port: str = "/dev/ttyUSB0", baudrate: int = 9600):
|
||||
super().__init__()
|
||||
self.serial_comm = SerialComm()
|
||||
self.i2c_protocol = I2CProtocol(self.serial_comm)
|
||||
self.i2c_devices = I2CDevices(self.i2c_protocol)
|
||||
self.laser_control = LaserControl(self.i2c_devices)
|
||||
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.is_connected = False
|
||||
self.command_queue = queue.Queue()
|
||||
self.running = True
|
||||
|
||||
# Last known laser state
|
||||
self.last_laser_state = {}
|
||||
|
||||
# Update interval for status polling
|
||||
self.last_status_update_time = 0
|
||||
self.status_update_interval = 1.0 # seconds
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def run(self):
|
||||
"""Main worker loop - processes commands from queue"""
|
||||
print(f"Genesis laser worker thread started - connecting to {self.port}")
|
||||
|
||||
# Try to connect on startup
|
||||
if self.connect():
|
||||
self.connected.emit()
|
||||
else:
|
||||
error_msg = f"Failed to connect to Genesis laser on {self.port}"
|
||||
print(error_msg)
|
||||
self.connection_failed.emit(error_msg)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Check for commands with timeout to allow periodic status updates
|
||||
try:
|
||||
cmd = self.command_queue.get(timeout=0.05) # 50ms timeout
|
||||
self.process_command(cmd)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Periodically update status if connected
|
||||
if self.is_connected:
|
||||
current_time = time.time()
|
||||
if current_time - self.last_status_update_time >= self.status_update_interval:
|
||||
self.update_laser_status()
|
||||
self.last_status_update_time = current_time
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in genesis worker loop: {e}")
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
# Cleanup on exit
|
||||
self.disconnect()
|
||||
print("Genesis laser worker thread stopped")
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Establish connection to the laser"""
|
||||
try:
|
||||
if self.serial_comm.connect(self.port, self.baudrate):
|
||||
self.is_connected = True
|
||||
print(f"Connected to Genesis laser on {self.port}")
|
||||
return True
|
||||
else:
|
||||
print(f"Failed to open serial port {self.port}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Connection error: {e}")
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the laser"""
|
||||
if self.is_connected:
|
||||
self.serial_comm.disconnect()
|
||||
self.is_connected = False
|
||||
self.disconnected.emit()
|
||||
print("Disconnected from Genesis laser")
|
||||
|
||||
def process_command(self, cmd: GenesisCommand):
|
||||
"""Process a command from the queue"""
|
||||
if not self.is_connected:
|
||||
self.error_occurred.emit("Laser not connected")
|
||||
return
|
||||
|
||||
try:
|
||||
if cmd.cmd_type == "query_all":
|
||||
result = self.query_all_status()
|
||||
self.query_completed.emit(result)
|
||||
elif cmd.cmd_type == "query_current":
|
||||
result = {"current": self.laser_control.get_current_actual()}
|
||||
self.query_completed.emit(result)
|
||||
elif cmd.cmd_type == "query_interlock":
|
||||
result = {"interlock": self.laser_control.get_interlock_status()}
|
||||
self.query_completed.emit(result)
|
||||
elif cmd.cmd_type == "set_current":
|
||||
value = cmd.params.get("value", 0)
|
||||
success = self.laser_control.set_current(int(value))
|
||||
self.query_completed.emit({"success": success})
|
||||
elif cmd.cmd_type == "set_shutter":
|
||||
state = cmd.params.get("state", False)
|
||||
success = self.laser_control.set_shutter(state)
|
||||
self.query_completed.emit({"success": success})
|
||||
else:
|
||||
self.error_occurred.emit(f"Unknown command: {cmd.cmd_type}")
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(f"Command execution error: {e}")
|
||||
|
||||
def update_laser_status(self):
|
||||
"""Query and emit current laser status"""
|
||||
if not self.is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
status = {
|
||||
"connected": True,
|
||||
"current_actual": self.laser_control.get_current_actual(),
|
||||
"interlock_status": self.laser_control.get_interlock_status(),
|
||||
"ldd_enable_status": self.laser_control.get_ldd_enable_status(),
|
||||
"psglue_in_status": self.laser_control.get_psglue_in_status(),
|
||||
"psglue_out_status": self.laser_control.get_psglue_out_status(),
|
||||
"head_dio_status": self.laser_control.get_head_dio_status(),
|
||||
}
|
||||
|
||||
# Only emit if something changed
|
||||
if status != self.last_laser_state:
|
||||
self.last_laser_state = status
|
||||
self.laser_info_updated.emit(status)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating laser status: {e}")
|
||||
|
||||
def query_all_status(self) -> dict:
|
||||
"""Query all laser status information"""
|
||||
return {
|
||||
"connected": True,
|
||||
"current_actual": self.laser_control.get_current_actual(),
|
||||
"interlock_status": self.laser_control.get_interlock_status(),
|
||||
"ldd_enable_status": self.laser_control.get_ldd_enable_status(),
|
||||
"psglue_in_status": self.laser_control.get_psglue_in_status(),
|
||||
"psglue_out_status": self.laser_control.get_psglue_out_status(),
|
||||
"head_dio_status": self.laser_control.get_head_dio_status(),
|
||||
}
|
||||
|
||||
def queue_command(self, cmd: GenesisCommand):
|
||||
"""Queue a command for execution"""
|
||||
self.command_queue.put(cmd)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the worker thread"""
|
||||
self.running = False
|
||||
+140
-21
@@ -126,17 +126,34 @@ class HeliosLaser:
|
||||
Send a query and read response.
|
||||
|
||||
Args:
|
||||
command: ASCII query command (without CR)
|
||||
command: ASCII query command (without CR or ?)
|
||||
|
||||
Returns:
|
||||
Response string or None if error
|
||||
Response value string or None if error
|
||||
"""
|
||||
try:
|
||||
# Clear any pending data in the buffer
|
||||
self.serial.reset_input_buffer()
|
||||
time.sleep(0.05)
|
||||
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
|
||||
try:
|
||||
response = self.serial.readline().decode('ascii').strip()
|
||||
time.sleep(0.2) # Give device time to respond
|
||||
|
||||
response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip()
|
||||
logger.debug(f"Query '{command}' response: {response}")
|
||||
|
||||
# Helios format: "COMMAND = VALUE UNIT"
|
||||
# Extract just the value part
|
||||
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
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
@@ -160,7 +177,12 @@ class HeliosLaser:
|
||||
# Convert frequency to period in nanoseconds
|
||||
period_ns = int(1e9 / frequency)
|
||||
|
||||
command = f"FP={period_ns}"
|
||||
# Clamp to valid range (8000-60000 ns)
|
||||
if not (8000 <= period_ns <= 60000):
|
||||
logger.error(f"Period {period_ns} ns out of range (8000-60000)")
|
||||
return False
|
||||
|
||||
command = f"LDF {period_ns}"
|
||||
return self._send_command(command)
|
||||
|
||||
def set_current_ma(self, current: int) -> bool:
|
||||
@@ -168,16 +190,16 @@ class HeliosLaser:
|
||||
Set pump diode current in mA.
|
||||
|
||||
Args:
|
||||
current: Current in mA (0 - 7000)
|
||||
current: Current in mA (0 - 2000 for this model)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not (0 <= current <= 7000):
|
||||
logger.error(f"Current {current} mA out of range (0-7000)")
|
||||
if not (0 <= current <= 2000):
|
||||
logger.error(f"Current {current} mA out of range (0-2000)")
|
||||
return False
|
||||
|
||||
command = f"PC={current}"
|
||||
command = f"LDS {current}"
|
||||
return self._send_command(command)
|
||||
|
||||
def set_pulse_mode(self, mode: PulseMode) -> bool:
|
||||
@@ -190,7 +212,7 @@ class HeliosLaser:
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
command = f"PM={mode.value}"
|
||||
command = f"LDG {mode.value}"
|
||||
return self._send_command(command)
|
||||
|
||||
def set_laser_enable(self, enable: bool) -> bool:
|
||||
@@ -203,7 +225,7 @@ class HeliosLaser:
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
command = f"LE={1 if enable else 0}"
|
||||
command = f"LDO {1 if enable else 0}"
|
||||
success = self._send_command(command)
|
||||
|
||||
if success:
|
||||
@@ -219,12 +241,12 @@ class HeliosLaser:
|
||||
Returns:
|
||||
True if laser is enabled
|
||||
"""
|
||||
response = self._query("LE?")
|
||||
response = self._query("LDO")
|
||||
if response:
|
||||
try:
|
||||
return int(response) == 1
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for LE?: {response}")
|
||||
logger.error(f"Invalid response for LDO: {response}")
|
||||
return False
|
||||
|
||||
def get_frequency_hz(self) -> Optional[int]:
|
||||
@@ -234,13 +256,13 @@ class HeliosLaser:
|
||||
Returns:
|
||||
Frequency in Hz or None if error
|
||||
"""
|
||||
response = self._query("FP?")
|
||||
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 FP?: {response}")
|
||||
logger.error(f"Invalid response for LDF: {response}")
|
||||
return None
|
||||
|
||||
def get_current_ma(self) -> Optional[int]:
|
||||
@@ -250,12 +272,12 @@ class HeliosLaser:
|
||||
Returns:
|
||||
Current in mA or None if error
|
||||
"""
|
||||
response = self._query("PC?")
|
||||
response = self._query("LDS")
|
||||
if response:
|
||||
try:
|
||||
return int(response)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for PC?: {response}")
|
||||
logger.error(f"Invalid response for LDS: {response}")
|
||||
return None
|
||||
|
||||
def get_power_mw(self) -> Optional[float]:
|
||||
@@ -265,12 +287,12 @@ class HeliosLaser:
|
||||
Returns:
|
||||
Power in mW or None if error
|
||||
"""
|
||||
response = self._query("PO?")
|
||||
response = self._query("HMP")
|
||||
if response:
|
||||
try:
|
||||
return float(response)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for PO?: {response}")
|
||||
logger.error(f"Invalid response for HMP: {response}")
|
||||
return None
|
||||
|
||||
def get_controller_serial(self) -> Optional[str]:
|
||||
@@ -280,7 +302,7 @@ class HeliosLaser:
|
||||
Returns:
|
||||
Serial number string or None if error
|
||||
"""
|
||||
return self._query("SN?")
|
||||
return self._query("CSR")
|
||||
|
||||
def get_head_serial(self) -> Optional[str]:
|
||||
"""
|
||||
@@ -289,7 +311,104 @@ class HeliosLaser:
|
||||
Returns:
|
||||
Serial number string or None if error
|
||||
"""
|
||||
return self._query("HSN?")
|
||||
return self._query("HSR")
|
||||
|
||||
def get_status_registers(self) -> tuple:
|
||||
"""
|
||||
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)
|
||||
|
||||
def reset_faults(self) -> bool:
|
||||
"""
|
||||
Execute the controller reset sequence to clear status registers.
|
||||
|
||||
Protocol-specified sequence: CCE 0 -> LCE 0 -> LER 0
|
||||
|
||||
Returns:
|
||||
True if all three commands sent successfully
|
||||
"""
|
||||
ok = True
|
||||
ok = self._send_command("CCE 0") and ok
|
||||
time.sleep(0.1)
|
||||
ok = self._send_command("LCE 0") and ok
|
||||
time.sleep(0.1)
|
||||
ok = self._send_command("LER 0") and ok
|
||||
if ok:
|
||||
logger.info("Fault reset sequence sent")
|
||||
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
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
raw = self.serial.read_until(b'\r')
|
||||
if not raw:
|
||||
raw = self.serial.read(self.serial.in_waiting)
|
||||
return raw.decode('ascii', errors='replace').strip()
|
||||
except Exception as e:
|
||||
logger.error(f"send_raw_command error: {e}")
|
||||
return None
|
||||
|
||||
def set_remote_enable(self, enable: bool) -> bool:
|
||||
"""
|
||||
Set the remote enable state (LRE - utility connector pin 8).
|
||||
|
||||
Args:
|
||||
enable: True to activate remote enable, False to deactivate
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
command = f"LRE {1 if enable else 0}"
|
||||
return self._send_command(command)
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor - ensure cleanup"""
|
||||
|
||||
@@ -265,6 +265,11 @@ class ThorlabsServoDriver():
|
||||
if(msg['status_bits'] & StatusBits.MOT_SB_HOMED):
|
||||
self.am_homed[ch] = True
|
||||
|
||||
if(msg['status_bits'] & StatusBits.MOT_SB_ENABLED):
|
||||
self.am_enabled[ch] = True
|
||||
else:
|
||||
self.am_enabled[ch] = False
|
||||
|
||||
def _update0x0464(self, msg):
|
||||
'''
|
||||
_update0x0464 - internal function for MOVE_COMPLETED messages.
|
||||
@@ -344,7 +349,7 @@ class ThorlabsServoDriver():
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
|
||||
self.send_and_wait(0x0443, timeout=timeout, chan_ident=1,
|
||||
self.send_and_wait(0x0443, timeout=timeout, retries=0, chan_ident=1,
|
||||
destination=axis, source=0x01)
|
||||
return
|
||||
|
||||
|
||||
@@ -1411,10 +1411,13 @@ class TektronixOscilloscopeBase:
|
||||
if not self.get_fastframe_state():
|
||||
raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)")
|
||||
|
||||
# Get the frame count
|
||||
frame_count = self.get_fastframe_count()
|
||||
if frame_count <= 0:
|
||||
raise RuntimeError(f"Invalid FastFrame count: {frame_count}")
|
||||
# Use the number of frames actually acquired, not the configured maximum.
|
||||
# If the stage stops early, fewer triggers arrive and the scope captures
|
||||
# fewer frames than configured — reading the configured count would block.
|
||||
acquired = int(self.query("ACQuire:NUMFRAMESACQuired?"))
|
||||
if acquired <= 0:
|
||||
raise RuntimeError(f"Scope acquired 0 FastFrame frames — no data to read")
|
||||
frame_count = acquired
|
||||
|
||||
# Send a single CURVe? query - scope will return all frames
|
||||
self.write("CURVe?")
|
||||
|
||||
@@ -4,11 +4,14 @@ Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library.
|
||||
Provides camera control, live streaming, and image capture capabilities.
|
||||
"""
|
||||
|
||||
import time
|
||||
import numpy as np
|
||||
from pyueye import ueye
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, QObject
|
||||
from PyQt6.QtGui import QImage
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,17 +27,21 @@ class UC480Camera(QObject):
|
||||
frame_ready = pyqtSignal(QImage) # Emitted when a new frame is captured
|
||||
error_occurred = pyqtSignal(str) # Emitted when an error occurs
|
||||
|
||||
def __init__(self, camera_id: int = 0):
|
||||
def __init__(self, camera_id: int = 1):
|
||||
"""
|
||||
Initialize the uC480 camera driver.
|
||||
|
||||
Args:
|
||||
camera_id: Camera ID (0 for first available camera)
|
||||
camera_id: Camera ID (1-based; use is_GetCameraList to find IDs)
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self.camera_id = camera_id
|
||||
self.h_cam = ueye.HIDS(camera_id)
|
||||
# IS_ALLOW_STARTER_FW_UPLOAD (0x10000): instructs the SDK to block
|
||||
# inside is_InitCamera until firmware upload and USB re-enumeration
|
||||
# finish. Without this flag, the handle becomes invalid the moment
|
||||
# the device reconnects and the SDK segfaults on the very next call.
|
||||
self.h_cam = ueye.HIDS(camera_id | 0x10000)
|
||||
self.is_initialized = False
|
||||
self.is_capturing = False
|
||||
|
||||
@@ -55,6 +62,9 @@ class UC480Camera(QObject):
|
||||
self.bytes_per_pixel = 3
|
||||
self.color_mode = ueye.IS_CM_BGR8_PACKED
|
||||
|
||||
# Lock to serialize parameter changes that require stopping live video
|
||||
self._settings_lock = threading.Lock()
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""
|
||||
Initialize the camera and allocate memory.
|
||||
@@ -63,9 +73,20 @@ class UC480Camera(QObject):
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Initialize camera
|
||||
# Initialize camera. After is_ExitCamera the UI124x series
|
||||
# resets and re-enumerates on USB (firmware reload), so retry
|
||||
# for up to ~10 s if IS_CANT_OPEN_DEVICE is returned.
|
||||
for attempt in range(20):
|
||||
ret = ueye.is_InitCamera(self.h_cam, None)
|
||||
if ret != ueye.IS_SUCCESS:
|
||||
if ret == ueye.IS_SUCCESS:
|
||||
break
|
||||
if ret == ueye.IS_CANT_OPEN_DEVICE and attempt < 19:
|
||||
logger.debug(f"Camera not ready (IS_CANT_OPEN_DEVICE), retrying ({attempt+1}/20)…")
|
||||
time.sleep(0.5)
|
||||
# h_cam value is consumed by a failed init on some SDK
|
||||
# versions; recreate it to avoid IS_INVALID_CAMERA_HANDLE
|
||||
self.h_cam = ueye.HIDS(self.camera_id | 0x10000)
|
||||
else:
|
||||
logger.error(f"Failed to initialize camera: {ret}")
|
||||
self.error_occurred.emit(f"Failed to initialize camera: {ret}")
|
||||
return False
|
||||
@@ -161,9 +182,12 @@ class UC480Camera(QObject):
|
||||
self.mem_ptr = None
|
||||
|
||||
if self.is_initialized:
|
||||
ueye.is_ExitCamera(self.h_cam)
|
||||
ret = ueye.is_ExitCamera(self.h_cam)
|
||||
self.is_initialized = False
|
||||
if ret == ueye.IS_SUCCESS:
|
||||
logger.info("Camera resources released")
|
||||
else:
|
||||
logger.error(f"is_ExitCamera failed: {ret} — camera handle may still be held by daemon")
|
||||
|
||||
def start_capture(self) -> bool:
|
||||
"""
|
||||
@@ -201,14 +225,37 @@ class UC480Camera(QObject):
|
||||
return True
|
||||
|
||||
ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
|
||||
self.is_capturing = False # Always reset, even if the call fails
|
||||
if ret != ueye.IS_SUCCESS:
|
||||
logger.error(f"Failed to stop capture: {ret}")
|
||||
return False
|
||||
|
||||
self.is_capturing = False
|
||||
logger.info("Video capture stopped")
|
||||
return True
|
||||
|
||||
@contextmanager
|
||||
def _capture_paused(self):
|
||||
"""
|
||||
Context manager that temporarily stops live video while a camera
|
||||
parameter is being changed, then restarts it. Many IDS cameras
|
||||
return IS_CANT_COMMUNICATE_WITH_DRIVER (17) or IS_NO_SUCCESS (-1)
|
||||
when gain/exposure commands are issued during active capture.
|
||||
"""
|
||||
with self._settings_lock:
|
||||
was_capturing = self.is_capturing
|
||||
if was_capturing:
|
||||
ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
|
||||
self.is_capturing = False
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if was_capturing:
|
||||
ret = ueye.is_CaptureVideo(self.h_cam, ueye.IS_DONT_WAIT)
|
||||
if ret == ueye.IS_SUCCESS:
|
||||
self.is_capturing = True
|
||||
else:
|
||||
logger.error(f"Failed to restart capture after settings change: {ret}")
|
||||
|
||||
def get_frame(self) -> Optional[QImage]:
|
||||
"""
|
||||
Capture a single frame from the camera.
|
||||
@@ -405,6 +452,13 @@ class UC480Camera(QObject):
|
||||
if ret == ueye.IS_SUCCESS:
|
||||
logger.debug(f"Master gain set to {master_gain}")
|
||||
return True
|
||||
elif ret == ueye.IS_CANT_COMMUNICATE_WITH_DRIVER:
|
||||
logger.error(
|
||||
f"Hardware gain not supported by this camera model "
|
||||
f"(IS_CANT_COMMUNICATE_WITH_DRIVER). "
|
||||
f"Consider using gain boost instead."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.error(f"Failed to set gain: {ret}")
|
||||
return False
|
||||
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Helios Laser Serial Communication Diagnostic Tool
|
||||
Helps troubleshoot communication issues with the Helios laser.
|
||||
"""
|
||||
|
||||
import serial
|
||||
import time
|
||||
import sys
|
||||
|
||||
def test_port(port, baudrate=9600):
|
||||
"""Test basic communication on a serial port."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Testing {port} at {baudrate} baud")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=1.0
|
||||
)
|
||||
print(f"✓ Port opened successfully")
|
||||
time.sleep(0.1)
|
||||
|
||||
# Try to query the controller serial number
|
||||
print("\nSending: 'SN?'")
|
||||
ser.write(b'SN?\r')
|
||||
time.sleep(0.5)
|
||||
|
||||
response = ser.readline().decode('ascii', errors='replace').strip()
|
||||
print(f"Response: '{response}'")
|
||||
|
||||
if response:
|
||||
print(f"✓ Got response: {response}")
|
||||
return True, response
|
||||
else:
|
||||
print(f"✗ No response received")
|
||||
|
||||
# Try head serial number
|
||||
print("\nSending: 'HSN?'")
|
||||
ser.write(b'HSN?\r')
|
||||
time.sleep(0.5)
|
||||
|
||||
response = ser.readline().decode('ascii', errors='replace').strip()
|
||||
print(f"Response: '{response}'")
|
||||
|
||||
if response:
|
||||
print(f"✓ Got response: {response}")
|
||||
ser.close()
|
||||
return True, response
|
||||
else:
|
||||
print(f"✗ No response received")
|
||||
|
||||
# Try laser enable status
|
||||
print("\nSending: 'LE?'")
|
||||
ser.write(b'LE?\r')
|
||||
time.sleep(0.5)
|
||||
|
||||
response = ser.readline().decode('ascii', errors='replace').strip()
|
||||
print(f"Response: '{response}'")
|
||||
|
||||
if response:
|
||||
print(f"✓ Got response: {response}")
|
||||
ser.close()
|
||||
return True, response
|
||||
else:
|
||||
print(f"✗ No response received")
|
||||
|
||||
ser.close()
|
||||
return False, "No response to any query"
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def test_raw_communication(port, baudrate=9600):
|
||||
"""Test raw serial communication and display hex."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Raw Communication Test: {port} at {baudrate} baud")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=2.0
|
||||
)
|
||||
print(f"✓ Port opened successfully")
|
||||
time.sleep(0.2)
|
||||
|
||||
# Send a simple query
|
||||
command = b'SN?\r'
|
||||
print(f"\nSending command (hex): {command.hex()}")
|
||||
print(f"Sending command (ascii): {command}")
|
||||
|
||||
ser.write(command)
|
||||
time.sleep(0.5)
|
||||
|
||||
# Read response byte by byte
|
||||
response = b''
|
||||
while True:
|
||||
byte = ser.read(1)
|
||||
if not byte:
|
||||
break
|
||||
response += byte
|
||||
if byte == b'\n' or byte == b'\r':
|
||||
break
|
||||
|
||||
print(f"\nRaw response (hex): {response.hex()}")
|
||||
print(f"Raw response (ascii): {response}")
|
||||
print(f"Response length: {len(response)} bytes")
|
||||
|
||||
# Check for common issues
|
||||
if not response:
|
||||
print("✗ No response - device may not be responding or wrong baud rate")
|
||||
elif response == b'\r' or response == b'\n':
|
||||
print("⚠ Only got line terminator - device may be echoing but not responding to command")
|
||||
else:
|
||||
print("✓ Got a response!")
|
||||
|
||||
ser.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_echo(port, baudrate=9600):
|
||||
"""Test if the device echoes commands back."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Echo Test: {port} at {baudrate} baud")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
# Send a test character
|
||||
test_char = b'T'
|
||||
print(f"Sending test character: {test_char}")
|
||||
ser.write(test_char)
|
||||
time.sleep(0.1)
|
||||
|
||||
echo = ser.read(1)
|
||||
if echo == test_char:
|
||||
print(f"✓ Device echoes input")
|
||||
elif echo:
|
||||
print(f"⚠ Device sent something but not the same: {echo}")
|
||||
else:
|
||||
print(f"✗ No echo")
|
||||
|
||||
ser.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run diagnostic tests."""
|
||||
port = "/dev/ttyUSB2"
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
port = sys.argv[1]
|
||||
|
||||
print(f"\n{'#'*60}")
|
||||
print(f"# Helios Laser Serial Diagnostic Tool")
|
||||
print(f"# Testing port: {port}")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
# Test standard baud rate
|
||||
success, response = test_port(port, 9600)
|
||||
|
||||
if not success:
|
||||
print("\n" + "="*60)
|
||||
print("Standard baud rate (9600) failed. Trying alternatives...")
|
||||
print("="*60)
|
||||
|
||||
# Try other common baud rates
|
||||
for baudrate in [115200, 19200, 4800, 2400]:
|
||||
success, response = test_port(port, baudrate)
|
||||
if success:
|
||||
print(f"\n✓ SUCCESS! Device responds at {baudrate} baud")
|
||||
break
|
||||
else:
|
||||
print(f"\n✓ SUCCESS! Device responds at 9600 baud")
|
||||
|
||||
# Run additional diagnostics
|
||||
print("\n")
|
||||
test_raw_communication(port, 9600)
|
||||
|
||||
print("\n")
|
||||
test_echo(port, 9600)
|
||||
|
||||
print(f"\n{'#'*60}")
|
||||
print("# Diagnostic Tests Complete")
|
||||
print(f"{'#'*60}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple serial terminal for manual Helios laser testing.
|
||||
Allows sending raw commands and viewing responses.
|
||||
"""
|
||||
|
||||
import serial
|
||||
import sys
|
||||
from threading import Thread
|
||||
import time
|
||||
|
||||
def read_from_port(ser):
|
||||
"""Read data from serial port and display it."""
|
||||
while True:
|
||||
try:
|
||||
if ser.in_waiting:
|
||||
data = ser.read(ser.in_waiting)
|
||||
print(f"\n[RX] {data.decode('ascii', errors='replace')}", end='')
|
||||
sys.stdout.flush()
|
||||
except:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
|
||||
def main():
|
||||
"""Run interactive serial terminal."""
|
||||
port = "/dev/ttyUSB2"
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
port = sys.argv[1]
|
||||
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=9600,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=0.1
|
||||
)
|
||||
print(f"Connected to {port} at 9600 baud")
|
||||
print("Type commands and press Enter. Type 'quit' to exit.\n")
|
||||
|
||||
# Start reader thread
|
||||
reader_thread = Thread(target=read_from_port, args=(ser,), daemon=True)
|
||||
reader_thread.start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("[TX] ")
|
||||
if user_input.lower() == 'quit':
|
||||
break
|
||||
|
||||
# Send command with carriage return
|
||||
command = user_input + '\r'
|
||||
ser.write(command.encode('ascii'))
|
||||
time.sleep(0.1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
ser.close()
|
||||
print("\nDisconnected")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to open {port}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+985
@@ -0,0 +1,985 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Helios Laser Test Application
|
||||
Simple PyQt6 GUI for testing and controlling the Helios laser.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import logging
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox,
|
||||
QStatusBar, QMessageBox, QTabWidget, QTextEdit
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from hardware.helios_laser import HeliosLaser, PulseMode
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status register bit definitions (Tables 8-1, 8-2, 8-3 — Helios manual)
|
||||
# Each entry: bit_number -> (severity, description, comment)
|
||||
# severity: 'C' = critical error, 'S' = status, 'I' = input error, '' = none
|
||||
# ---------------------------------------------------------------------------
|
||||
_LER_FLAGS = {
|
||||
0: ('C', 'Controller temperature failure (resonator/SHG/q-switch)',
|
||||
'Check CCE register for details'),
|
||||
1: ('S', 'Trigger input active',
|
||||
'High when trigger signal applied or laser in continuous pulsing'),
|
||||
2: ('I', 'Command error',
|
||||
'Unknown command sent to controller'),
|
||||
3: ('C', 'Laser disable pin open (utility connector)',
|
||||
'Shuts down pump diodes; reset LER 0 required to restart'),
|
||||
4: ('C', 'Internal hardware failure',
|
||||
'Contact Coherent'),
|
||||
5: ('C', 'Over voltage laser diode',
|
||||
'Check for open circuit or voltage spikes'),
|
||||
6: ('C', 'Internal hardware failure',
|
||||
'Contact Coherent'),
|
||||
7: ('C', 'Controller temperature failure at pump diodes',
|
||||
'Check LCE register for details'),
|
||||
8: ('S', 'Laser start delay (60 s warmup)',
|
||||
'Laser cannot be started yet; status error LED flashing'),
|
||||
9: ('C', 'Internal hardware failure',
|
||||
'Check environment for strong EMI; contact Coherent'),
|
||||
10: ('C', 'Internal hardware failure',
|
||||
'Check environment for strong EMI; contact Coherent'),
|
||||
11: ('C', 'Internal hardware failure',
|
||||
'Check environment for strong EMI; contact Coherent'),
|
||||
12: ('S', 'Slave controller error (remote input)',
|
||||
'Valid only for master controller coupled with a slave'),
|
||||
13: ('', 'Laserhead not found',
|
||||
'Head not connected / not found; check EMI; ignore for double-electronic slave'),
|
||||
14: ('', 'Laserhead I\u00b2C acknowledge error',
|
||||
'Check environment for strong EMI; ignore for double-electronic slave'),
|
||||
15: ('S', 'Range-Error (not critical)',
|
||||
'Input value out of range'),
|
||||
}
|
||||
|
||||
_LCE_FLAGS = {
|
||||
0: ('C', 'Pump diode over/under temperature',
|
||||
'Limit exceeded (<10\u00b0C or >60\u00b0C); check head cooling'),
|
||||
1: ('C', 'Internal hardware failure',
|
||||
'Contact Coherent'),
|
||||
2: ('C', 'Pump diode temperature out of range',
|
||||
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
||||
3: ('C', 'Pump diode current critical',
|
||||
'Current set too close to current limit'),
|
||||
4: ('C', 'Pump diode temperature out of limit',
|
||||
'Pump diode temperature is out of limit'),
|
||||
5: ('S', 'Door switch open',
|
||||
'Close utility connector pin 2 permanently to pin 9 (GND)'),
|
||||
7: ('C', 'Pump diode NTC error',
|
||||
'Invalid temperature measured or NTC broken'),
|
||||
8: ('C', 'Laser diode power stage over temperature',
|
||||
'Temp <10\u00b0C or >65\u00b0C at controller; check controller cooling'),
|
||||
9: ('C', 'Internal hardware failure',
|
||||
'Check environment for strong EMI; contact Coherent'),
|
||||
10: ('C', 'Internal hardware failure',
|
||||
'Check environment for strong EMI; contact Coherent'),
|
||||
11: ('C', 'Internal hardware failure',
|
||||
'Check environment for strong EMI; contact Coherent'),
|
||||
15: ('S', 'Range-Error (not critical)',
|
||||
'Input value out of range'),
|
||||
}
|
||||
|
||||
_CCE_FLAGS = {
|
||||
0: ('C', 'Resonator/SHG under/over temperature',
|
||||
'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'),
|
||||
1: ('C', 'Resonator/SHG NTC failure',
|
||||
'Temperature sensor broken or disconnected'),
|
||||
2: ('C', 'Resonator/SHG temperature out of range',
|
||||
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
||||
3: ('C', 'Q-switch ADC / temperature readout failure',
|
||||
'Internal hardware error or no NTC connected'),
|
||||
4: ('C', 'Q-switch temperature out of range',
|
||||
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
||||
5: ('C', 'Q-switch under/over temperature',
|
||||
'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'),
|
||||
7: ('C', 'Q-switch NTC failure',
|
||||
'Internal hardware error or no NTC connected'),
|
||||
8: ('C', 'Internal hardware failure',
|
||||
'Contact Coherent'),
|
||||
15: ('S', 'Range-Error (not critical)',
|
||||
'Input value out of range'),
|
||||
}
|
||||
|
||||
_SEVERITY_LABEL = {'C': '[CRIT]', 'S': '[STAT]', 'I': '[INPT]', '': '[INFO]'}
|
||||
|
||||
|
||||
def _decode_register(flags_dict: dict, value: int) -> list:
|
||||
"""Return list of (bit, severity, description, comment) for each set bit."""
|
||||
active = []
|
||||
for bit, (sev, desc, comment) in flags_dict.items():
|
||||
if value & (1 << bit):
|
||||
active.append((bit, sev, desc, comment))
|
||||
return active
|
||||
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaserWorker(QObject):
|
||||
"""Worker thread for laser operations to prevent UI blocking."""
|
||||
|
||||
# Signals
|
||||
operation_complete = pyqtSignal(bool, str) # success, message
|
||||
frequency_updated = pyqtSignal(int)
|
||||
current_updated = pyqtSignal(int)
|
||||
power_updated = pyqtSignal(float)
|
||||
enabled_updated = pyqtSignal(bool)
|
||||
serial_updated = pyqtSignal(str, str) # controller_sn, head_sn
|
||||
status_registers_updated = pyqtSignal(object, object, object) # ler, lce, cce (int or None)
|
||||
remote_enable_updated = pyqtSignal(object) # bool or None
|
||||
raw_response_received = pyqtSignal(str, str) # command, response
|
||||
|
||||
def __init__(self, laser: HeliosLaser):
|
||||
super().__init__()
|
||||
self.laser = laser
|
||||
|
||||
def set_frequency(self, freq: int):
|
||||
try:
|
||||
success = self.laser.set_frequency_hz(freq)
|
||||
msg = f"Frequency set to {freq} Hz" if success else "Failed to set frequency"
|
||||
self.operation_complete.emit(success, msg)
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def set_current(self, current: int):
|
||||
try:
|
||||
success = self.laser.set_current_ma(current)
|
||||
msg = f"Current set to {current} mA" if success else "Failed to set current"
|
||||
self.operation_complete.emit(success, msg)
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def set_pulse_mode(self, mode: int):
|
||||
try:
|
||||
pulse_mode = PulseMode(mode)
|
||||
success = self.laser.set_pulse_mode(pulse_mode)
|
||||
msg = f"Pulse mode set to {pulse_mode.name}" if success else "Failed to set pulse mode"
|
||||
self.operation_complete.emit(success, msg)
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def set_laser_enable(self, enable: bool):
|
||||
try:
|
||||
success = self.laser.set_laser_enable(enable)
|
||||
state = "enabled" if enable else "disabled"
|
||||
msg = f"Laser {state}" if success else f"Failed to {state} laser"
|
||||
self.operation_complete.emit(success, msg)
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_frequency(self):
|
||||
try:
|
||||
freq = self.laser.get_frequency_hz()
|
||||
if freq is not None:
|
||||
self.frequency_updated.emit(freq)
|
||||
self.operation_complete.emit(True, f"Frequency: {freq} Hz")
|
||||
else:
|
||||
self.operation_complete.emit(False, "Failed to query frequency")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_current(self):
|
||||
try:
|
||||
current = self.laser.get_current_ma()
|
||||
if current is not None:
|
||||
self.current_updated.emit(current)
|
||||
self.operation_complete.emit(True, f"Current: {current} mA")
|
||||
else:
|
||||
self.operation_complete.emit(False, "Failed to query current")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_power(self):
|
||||
try:
|
||||
power = self.laser.get_power_mw()
|
||||
if power is not None:
|
||||
self.power_updated.emit(power)
|
||||
self.operation_complete.emit(True, f"Power: {power:.2f} mW")
|
||||
else:
|
||||
self.operation_complete.emit(False, "Failed to query power")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_enabled(self):
|
||||
try:
|
||||
enabled = self.laser.is_laser_enabled()
|
||||
self.enabled_updated.emit(enabled)
|
||||
state = "enabled" if enabled else "disabled"
|
||||
self.operation_complete.emit(True, f"Laser is {state}")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_serials(self):
|
||||
try:
|
||||
controller_sn = self.laser.get_controller_serial()
|
||||
head_sn = self.laser.get_head_serial()
|
||||
|
||||
if controller_sn and head_sn:
|
||||
self.serial_updated.emit(controller_sn, head_sn)
|
||||
msg = f"Controller: {controller_sn}, Head: {head_sn}"
|
||||
self.operation_complete.emit(True, msg)
|
||||
else:
|
||||
self.operation_complete.emit(False, "Failed to query serial numbers")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_status_registers(self):
|
||||
try:
|
||||
ler, lce, cce = self.laser.get_status_registers()
|
||||
self.status_registers_updated.emit(ler, lce, cce)
|
||||
self.operation_complete.emit(True, f"Status: LER={ler} LCE={lce} CCE={cce}")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_remote_enable(self):
|
||||
try:
|
||||
state = self.laser.get_remote_enable()
|
||||
self.remote_enable_updated.emit(state)
|
||||
if state is not None:
|
||||
self.operation_complete.emit(True, f"Remote enable (LRE): {'ON' if state else 'OFF'}")
|
||||
else:
|
||||
self.operation_complete.emit(False, "Failed to query remote enable")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def set_remote_enable(self, enable: bool):
|
||||
try:
|
||||
success = self.laser.set_remote_enable(enable)
|
||||
state = "ON" if enable else "OFF"
|
||||
msg = f"Remote enable (LRE) set to {state}" if success else "Failed to set remote enable"
|
||||
self.operation_complete.emit(success, msg)
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def do_reset_faults(self):
|
||||
try:
|
||||
success = self.laser.reset_faults()
|
||||
msg = "Fault reset sequence sent (CCE 0 → LCE 0 → LER 0)" if success else "Failed to send reset sequence"
|
||||
self.operation_complete.emit(success, msg)
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def do_ler_reset(self):
|
||||
try:
|
||||
success = self.laser._send_command("LER 0")
|
||||
msg = "LER 0 sent" if success else "Failed to send LER 0"
|
||||
self.operation_complete.emit(success, msg)
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def send_raw(self, cmd: str):
|
||||
try:
|
||||
response = self.laser.send_raw_command(cmd)
|
||||
if response is not None:
|
||||
self.raw_response_received.emit(cmd, response)
|
||||
self.operation_complete.emit(True, f"TX: {cmd!r} RX: {response!r}")
|
||||
else:
|
||||
self.operation_complete.emit(False, f"No response for: {cmd!r}")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
|
||||
class HeliosTestApp(QMainWindow):
|
||||
"""Main application window for Helios laser testing."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.laser = HeliosLaser()
|
||||
self.worker = None
|
||||
self.worker_thread = None
|
||||
|
||||
self.init_ui()
|
||||
self.update_port_list()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialize the user interface."""
|
||||
self.setWindowTitle("Helios Laser Test Application")
|
||||
self.setGeometry(100, 100, 900, 700)
|
||||
|
||||
# Main widget and layout
|
||||
main_widget = QWidget()
|
||||
self.setCentralWidget(main_widget)
|
||||
main_layout = QVBoxLayout(main_widget)
|
||||
|
||||
# Connection group
|
||||
connection_group = self.create_connection_group()
|
||||
main_layout.addWidget(connection_group)
|
||||
|
||||
# Tabs for control and monitoring
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self.create_control_tab(), "Control")
|
||||
tabs.addTab(self.create_monitor_tab(), "Monitor")
|
||||
tabs.addTab(self.create_terminal_tab(), "Terminal")
|
||||
main_layout.addWidget(tabs)
|
||||
|
||||
# Status bar
|
||||
self.statusBar().showMessage("Disconnected")
|
||||
|
||||
def create_connection_group(self) -> QGroupBox:
|
||||
"""Create the connection control group."""
|
||||
group = QGroupBox("Connection")
|
||||
layout = QHBoxLayout()
|
||||
|
||||
# Port selection
|
||||
layout.addWidget(QLabel("Serial Port:"))
|
||||
self.combo_port = QComboBox()
|
||||
self.combo_port.setMinimumWidth(150)
|
||||
layout.addWidget(self.combo_port)
|
||||
|
||||
# Refresh ports button
|
||||
btn_refresh = QPushButton("Refresh Ports")
|
||||
btn_refresh.clicked.connect(self.update_port_list)
|
||||
layout.addWidget(btn_refresh)
|
||||
|
||||
# Connect button
|
||||
self.btn_connect = QPushButton("Connect")
|
||||
self.btn_connect.clicked.connect(self.toggle_connection)
|
||||
self.btn_connect.setMinimumWidth(100)
|
||||
layout.addWidget(self.btn_connect)
|
||||
|
||||
# Connection status
|
||||
self.lbl_status = QLabel("Status: Disconnected")
|
||||
font = self.lbl_status.font()
|
||||
font.setBold(True)
|
||||
self.lbl_status.setFont(font)
|
||||
layout.addWidget(self.lbl_status)
|
||||
|
||||
layout.addStretch()
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def create_control_tab(self) -> QWidget:
|
||||
"""Create the control tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
|
||||
# Frequency control
|
||||
freq_group = QGroupBox("Frequency Control")
|
||||
freq_layout = QHBoxLayout()
|
||||
freq_layout.addWidget(QLabel("Frequency (Hz):"))
|
||||
self.spin_frequency = QSpinBox()
|
||||
self.spin_frequency.setRange(16700, 125000)
|
||||
self.spin_frequency.setValue(50000)
|
||||
self.spin_frequency.setSingleStep(1000)
|
||||
freq_layout.addWidget(self.spin_frequency)
|
||||
|
||||
btn_set_freq = QPushButton("Set Frequency")
|
||||
btn_set_freq.clicked.connect(self.on_set_frequency)
|
||||
btn_set_freq.setMaximumWidth(150)
|
||||
freq_layout.addWidget(btn_set_freq)
|
||||
|
||||
btn_get_freq = QPushButton("Query")
|
||||
btn_get_freq.clicked.connect(self.on_query_frequency)
|
||||
btn_get_freq.setMaximumWidth(100)
|
||||
freq_layout.addWidget(btn_get_freq)
|
||||
freq_layout.addStretch()
|
||||
freq_group.setLayout(freq_layout)
|
||||
layout.addWidget(freq_group)
|
||||
|
||||
# Current control
|
||||
current_group = QGroupBox("Current Control")
|
||||
current_layout = QHBoxLayout()
|
||||
current_layout.addWidget(QLabel("Current (mA):"))
|
||||
self.spin_current = QSpinBox()
|
||||
self.spin_current.setRange(0, 2000)
|
||||
self.spin_current.setValue(1000)
|
||||
self.spin_current.setSingleStep(100)
|
||||
current_layout.addWidget(self.spin_current)
|
||||
|
||||
btn_set_current = QPushButton("Set Current")
|
||||
btn_set_current.clicked.connect(self.on_set_current)
|
||||
btn_set_current.setMaximumWidth(150)
|
||||
current_layout.addWidget(btn_set_current)
|
||||
|
||||
btn_get_current = QPushButton("Query")
|
||||
btn_get_current.clicked.connect(self.on_query_current)
|
||||
btn_get_current.setMaximumWidth(100)
|
||||
current_layout.addWidget(btn_get_current)
|
||||
current_layout.addStretch()
|
||||
current_group.setLayout(current_layout)
|
||||
layout.addWidget(current_group)
|
||||
|
||||
# Pulse mode control
|
||||
mode_group = QGroupBox("Pulse Mode Control")
|
||||
mode_layout = QHBoxLayout()
|
||||
mode_layout.addWidget(QLabel("Pulse Mode:"))
|
||||
self.combo_mode = QComboBox()
|
||||
self.combo_mode.addItem("Single Pulse", PulseMode.SINGLE_PULSE.value)
|
||||
self.combo_mode.addItem("Continuous Gating", PulseMode.CONTINUOUS_GATING.value)
|
||||
self.combo_mode.addItem("Continuous Pulsing", PulseMode.CONTINUOUS_PULSING.value)
|
||||
mode_layout.addWidget(self.combo_mode)
|
||||
|
||||
btn_set_mode = QPushButton("Set Mode")
|
||||
btn_set_mode.clicked.connect(self.on_set_mode)
|
||||
btn_set_mode.setMaximumWidth(150)
|
||||
mode_layout.addWidget(btn_set_mode)
|
||||
mode_layout.addStretch()
|
||||
mode_group.setLayout(mode_layout)
|
||||
layout.addWidget(mode_group)
|
||||
|
||||
# Laser enable/disable
|
||||
enable_group = QGroupBox("Laser Control")
|
||||
enable_layout = QHBoxLayout()
|
||||
|
||||
self.btn_enable = QPushButton("Enable Laser")
|
||||
self.btn_enable.setStyleSheet("background-color: lightgreen")
|
||||
self.btn_enable.clicked.connect(self.on_enable_laser)
|
||||
self.btn_enable.setMaximumWidth(150)
|
||||
enable_layout.addWidget(self.btn_enable)
|
||||
|
||||
self.btn_disable = QPushButton("Disable Laser")
|
||||
self.btn_disable.setStyleSheet("background-color: lightcoral")
|
||||
self.btn_disable.clicked.connect(self.on_disable_laser)
|
||||
self.btn_disable.setMaximumWidth(150)
|
||||
enable_layout.addWidget(self.btn_disable)
|
||||
|
||||
btn_check_enabled = QPushButton("Query Status")
|
||||
btn_check_enabled.clicked.connect(self.on_query_enabled)
|
||||
btn_check_enabled.setMaximumWidth(150)
|
||||
enable_layout.addWidget(btn_check_enabled)
|
||||
|
||||
self.lbl_enabled = QLabel("Status: Unknown")
|
||||
font = self.lbl_enabled.font()
|
||||
font.setBold(True)
|
||||
self.lbl_enabled.setFont(font)
|
||||
enable_layout.addWidget(self.lbl_enabled)
|
||||
enable_layout.addStretch()
|
||||
enable_group.setLayout(enable_layout)
|
||||
layout.addWidget(enable_group)
|
||||
|
||||
# Status & Safety
|
||||
safety_group = QGroupBox("Status && Safety")
|
||||
safety_layout = QVBoxLayout()
|
||||
|
||||
# Row 1: status registers + query/reset buttons
|
||||
reg_row = QHBoxLayout()
|
||||
|
||||
btn_query_status = QPushButton("Query Status")
|
||||
btn_query_status.clicked.connect(self.on_query_status)
|
||||
btn_query_status.setMaximumWidth(130)
|
||||
reg_row.addWidget(btn_query_status)
|
||||
|
||||
btn_reset_faults = QPushButton("Reset Faults")
|
||||
btn_reset_faults.setStyleSheet("background-color: #FFD700")
|
||||
btn_reset_faults.clicked.connect(self.on_reset_faults)
|
||||
btn_reset_faults.setMaximumWidth(130)
|
||||
reg_row.addWidget(btn_reset_faults)
|
||||
|
||||
btn_ler_reset = QPushButton("LER 0 Reset")
|
||||
btn_ler_reset.setStyleSheet("background-color: #FFA500")
|
||||
btn_ler_reset.setToolTip("Send LER 0 only (clears controller error register)")
|
||||
btn_ler_reset.clicked.connect(self.on_ler_reset)
|
||||
btn_ler_reset.setMaximumWidth(130)
|
||||
reg_row.addWidget(btn_ler_reset)
|
||||
|
||||
reg_row.addSpacing(20)
|
||||
reg_row.addWidget(QLabel("LER:"))
|
||||
self.lbl_ler = QLabel("—")
|
||||
bold_font = self.lbl_ler.font()
|
||||
bold_font.setBold(True)
|
||||
self.lbl_ler.setFont(bold_font)
|
||||
reg_row.addWidget(self.lbl_ler)
|
||||
|
||||
reg_row.addWidget(QLabel("LCE:"))
|
||||
self.lbl_lce = QLabel("—")
|
||||
self.lbl_lce.setFont(bold_font)
|
||||
reg_row.addWidget(self.lbl_lce)
|
||||
|
||||
reg_row.addWidget(QLabel("CCE:"))
|
||||
self.lbl_cce = QLabel("—")
|
||||
self.lbl_cce.setFont(bold_font)
|
||||
reg_row.addWidget(self.lbl_cce)
|
||||
|
||||
reg_row.addSpacing(20)
|
||||
reg_row.addWidget(QLabel("Interlock:"))
|
||||
self.lbl_interlock = QLabel("UNKNOWN")
|
||||
self.lbl_interlock.setFont(bold_font)
|
||||
self.lbl_interlock.setMinimumWidth(80)
|
||||
reg_row.addWidget(self.lbl_interlock)
|
||||
|
||||
reg_row.addStretch()
|
||||
safety_layout.addLayout(reg_row)
|
||||
|
||||
# Row 2: remote enable (LRE / pin 8)
|
||||
lre_row = QHBoxLayout()
|
||||
|
||||
btn_lre_on = QPushButton("Remote Enable ON")
|
||||
btn_lre_on.setStyleSheet("background-color: lightgreen")
|
||||
btn_lre_on.clicked.connect(lambda: self.on_set_remote_enable(True))
|
||||
btn_lre_on.setMaximumWidth(160)
|
||||
lre_row.addWidget(btn_lre_on)
|
||||
|
||||
btn_lre_off = QPushButton("Remote Enable OFF")
|
||||
btn_lre_off.setStyleSheet("background-color: lightcoral")
|
||||
btn_lre_off.clicked.connect(lambda: self.on_set_remote_enable(False))
|
||||
btn_lre_off.setMaximumWidth(160)
|
||||
lre_row.addWidget(btn_lre_off)
|
||||
|
||||
btn_lre_query = QPushButton("Query LRE")
|
||||
btn_lre_query.clicked.connect(self.on_query_remote_enable)
|
||||
btn_lre_query.setMaximumWidth(120)
|
||||
lre_row.addWidget(btn_lre_query)
|
||||
|
||||
lre_row.addSpacing(20)
|
||||
lre_row.addWidget(QLabel("Enable Pin (LRE):"))
|
||||
self.lbl_lre = QLabel("UNKNOWN")
|
||||
self.lbl_lre.setFont(bold_font)
|
||||
self.lbl_lre.setMinimumWidth(80)
|
||||
lre_row.addWidget(self.lbl_lre)
|
||||
|
||||
lre_row.addStretch()
|
||||
safety_layout.addLayout(lre_row)
|
||||
|
||||
# Row 3: decoded register flags
|
||||
self.text_register_decode = QTextEdit()
|
||||
self.text_register_decode.setReadOnly(True)
|
||||
self.text_register_decode.setMinimumHeight(110)
|
||||
self.text_register_decode.setMaximumHeight(160)
|
||||
self.text_register_decode.setPlaceholderText(
|
||||
"Register flags will appear here after querying status…"
|
||||
)
|
||||
mono_font = QFont("Monospace")
|
||||
mono_font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
self.text_register_decode.setFont(mono_font)
|
||||
safety_layout.addWidget(self.text_register_decode)
|
||||
|
||||
safety_group.setLayout(safety_layout)
|
||||
layout.addWidget(safety_group)
|
||||
|
||||
layout.addStretch()
|
||||
return widget
|
||||
|
||||
def create_monitor_tab(self) -> QWidget:
|
||||
"""Create the monitoring/information tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
|
||||
# System information
|
||||
info_group = QGroupBox("System Information")
|
||||
info_layout = QHBoxLayout()
|
||||
|
||||
btn_query_all = QPushButton("Query All")
|
||||
btn_query_all.clicked.connect(self.on_query_all)
|
||||
info_layout.addWidget(btn_query_all)
|
||||
|
||||
info_layout.addWidget(QLabel("Controller SN:"))
|
||||
self.lbl_controller_sn = QLineEdit()
|
||||
self.lbl_controller_sn.setReadOnly(True)
|
||||
info_layout.addWidget(self.lbl_controller_sn)
|
||||
|
||||
info_layout.addWidget(QLabel("Head SN:"))
|
||||
self.lbl_head_sn = QLineEdit()
|
||||
self.lbl_head_sn.setReadOnly(True)
|
||||
info_layout.addWidget(self.lbl_head_sn)
|
||||
info_group.setLayout(info_layout)
|
||||
layout.addWidget(info_group)
|
||||
|
||||
# Power monitoring
|
||||
power_group = QGroupBox("Power Monitoring")
|
||||
power_layout = QHBoxLayout()
|
||||
|
||||
btn_get_power = QPushButton("Query Power")
|
||||
btn_get_power.clicked.connect(self.on_query_power)
|
||||
power_layout.addWidget(btn_get_power)
|
||||
|
||||
power_layout.addWidget(QLabel("Output Power:"))
|
||||
self.lbl_power = QLineEdit()
|
||||
self.lbl_power.setReadOnly(True)
|
||||
self.lbl_power.setMaximumWidth(150)
|
||||
power_layout.addWidget(self.lbl_power)
|
||||
|
||||
power_layout.addWidget(QLabel("mW"))
|
||||
power_layout.addStretch()
|
||||
power_group.setLayout(power_layout)
|
||||
layout.addWidget(power_group)
|
||||
|
||||
# Log/Message display
|
||||
log_group = QGroupBox("Messages")
|
||||
log_layout = QVBoxLayout()
|
||||
self.text_log = QTextEdit()
|
||||
self.text_log.setReadOnly(True)
|
||||
self.text_log.setMaximumHeight(300)
|
||||
log_layout.addWidget(self.text_log)
|
||||
log_group.setLayout(log_layout)
|
||||
layout.addWidget(log_group)
|
||||
|
||||
layout.addStretch()
|
||||
return widget
|
||||
|
||||
def create_terminal_tab(self) -> QWidget:
|
||||
"""Create the raw command terminal tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
|
||||
# Input row
|
||||
cmd_group = QGroupBox("Raw Command")
|
||||
cmd_layout = QHBoxLayout()
|
||||
|
||||
cmd_layout.addWidget(QLabel("Command:"))
|
||||
self.le_raw_cmd = QLineEdit()
|
||||
self.le_raw_cmd.setPlaceholderText("e.g. LDF or LDS 1000 or LER 0")
|
||||
self.le_raw_cmd.returnPressed.connect(self.on_send_raw)
|
||||
cmd_layout.addWidget(self.le_raw_cmd)
|
||||
|
||||
btn_send_raw = QPushButton("Send")
|
||||
btn_send_raw.setMaximumWidth(80)
|
||||
btn_send_raw.clicked.connect(self.on_send_raw)
|
||||
cmd_layout.addWidget(btn_send_raw)
|
||||
|
||||
btn_clear_terminal = QPushButton("Clear")
|
||||
btn_clear_terminal.setMaximumWidth(70)
|
||||
btn_clear_terminal.clicked.connect(lambda: self.text_terminal.clear())
|
||||
cmd_layout.addWidget(btn_clear_terminal)
|
||||
|
||||
cmd_group.setLayout(cmd_layout)
|
||||
layout.addWidget(cmd_group)
|
||||
|
||||
# Terminal output
|
||||
term_group = QGroupBox("Response Log")
|
||||
term_layout = QVBoxLayout()
|
||||
self.text_terminal = QTextEdit()
|
||||
self.text_terminal.setReadOnly(True)
|
||||
mono_font = QFont("Monospace")
|
||||
mono_font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
self.text_terminal.setFont(mono_font)
|
||||
self.text_terminal.setPlaceholderText(
|
||||
"TX/RX pairs will appear here.\n"
|
||||
"Commands are sent as-is with a trailing CR.\n"
|
||||
"No ? suffix needed — send the command name to query (e.g. LDF)."
|
||||
)
|
||||
term_layout.addWidget(self.text_terminal)
|
||||
term_group.setLayout(term_layout)
|
||||
layout.addWidget(term_group)
|
||||
|
||||
return widget
|
||||
|
||||
def update_port_list(self):
|
||||
"""Update the available serial ports."""
|
||||
self.combo_port.clear()
|
||||
try:
|
||||
ports = HeliosLaser.list_available_ports()
|
||||
if ports:
|
||||
self.combo_port.addItems(ports)
|
||||
else:
|
||||
self.combo_port.addItem("No ports found")
|
||||
except Exception as e:
|
||||
self.combo_port.addItem(f"Error: {e}")
|
||||
logger.error(f"Error listing ports: {e}")
|
||||
|
||||
def toggle_connection(self):
|
||||
"""Toggle connection to the laser."""
|
||||
if self.laser.is_connected:
|
||||
self.disconnect_laser()
|
||||
else:
|
||||
self.connect_laser()
|
||||
|
||||
def connect_laser(self):
|
||||
"""Connect to the laser."""
|
||||
port = self.combo_port.currentText()
|
||||
if not port or "No ports" in port or "Error" in port:
|
||||
QMessageBox.warning(self, "Connection Error", "No valid port selected")
|
||||
return
|
||||
|
||||
if self.laser.connect(port):
|
||||
self.lbl_status.setText(f"Status: Connected to {port}")
|
||||
self.lbl_status.setStyleSheet("color: green")
|
||||
self.btn_connect.setText("Disconnect")
|
||||
self.combo_port.setEnabled(False)
|
||||
|
||||
# Create worker for async operations
|
||||
self.worker = LaserWorker(self.laser)
|
||||
self.worker_thread = QThread()
|
||||
self.worker.moveToThread(self.worker_thread)
|
||||
self.worker.operation_complete.connect(self.on_operation_complete)
|
||||
self.worker.frequency_updated.connect(self.on_frequency_updated)
|
||||
self.worker.current_updated.connect(self.on_current_updated)
|
||||
self.worker.power_updated.connect(self.on_power_updated)
|
||||
self.worker.enabled_updated.connect(self.on_enabled_updated)
|
||||
self.worker.serial_updated.connect(self.on_serial_updated)
|
||||
self.worker.status_registers_updated.connect(self.on_status_registers_updated)
|
||||
self.worker.remote_enable_updated.connect(self.on_remote_enable_updated)
|
||||
self.worker.raw_response_received.connect(self.on_raw_response)
|
||||
self.worker_thread.start()
|
||||
|
||||
self.statusBar().showMessage(f"Connected to {port}")
|
||||
logger.info(f"Connected to {port}")
|
||||
else:
|
||||
QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}")
|
||||
|
||||
def disconnect_laser(self):
|
||||
"""Disconnect from the laser."""
|
||||
if self.worker_thread:
|
||||
self.worker_thread.quit()
|
||||
self.worker_thread.wait()
|
||||
|
||||
self.laser.disconnect()
|
||||
self.lbl_status.setText("Status: Disconnected")
|
||||
self.lbl_status.setStyleSheet("color: red")
|
||||
self.btn_connect.setText("Connect")
|
||||
self.combo_port.setEnabled(True)
|
||||
self.statusBar().showMessage("Disconnected")
|
||||
logger.info("Disconnected from laser")
|
||||
|
||||
def on_set_frequency(self):
|
||||
"""Set the laser frequency."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
freq = self.spin_frequency.value()
|
||||
self.worker.set_frequency(freq)
|
||||
|
||||
def on_query_frequency(self):
|
||||
"""Query the laser frequency."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
self.worker.query_frequency()
|
||||
|
||||
def on_set_current(self):
|
||||
"""Set the laser current."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
current = self.spin_current.value()
|
||||
self.worker.set_current(current)
|
||||
|
||||
def on_query_current(self):
|
||||
"""Query the laser current."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
self.worker.query_current()
|
||||
|
||||
def on_set_mode(self):
|
||||
"""Set the laser pulse mode."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
mode = self.combo_mode.currentData()
|
||||
self.worker.set_pulse_mode(mode)
|
||||
|
||||
def on_enable_laser(self):
|
||||
"""Enable the laser."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
self.worker.set_laser_enable(True)
|
||||
|
||||
def on_disable_laser(self):
|
||||
"""Disable the laser."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
self.worker.set_laser_enable(False)
|
||||
|
||||
def on_query_enabled(self):
|
||||
"""Query if laser is enabled."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
self.worker.query_enabled()
|
||||
|
||||
def on_query_power(self):
|
||||
"""Query the laser output power."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
self.worker.query_power()
|
||||
|
||||
def on_query_all(self):
|
||||
"""Query all laser parameters."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
|
||||
self.worker.query_serials()
|
||||
self.worker.query_frequency()
|
||||
self.worker.query_current()
|
||||
self.worker.query_power()
|
||||
self.worker.query_enabled()
|
||||
self.worker.query_status_registers()
|
||||
self.worker.query_remote_enable()
|
||||
|
||||
def on_query_status(self):
|
||||
"""Query LER/LCE/CCE status registers."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
self.worker.query_status_registers()
|
||||
|
||||
def on_reset_faults(self):
|
||||
"""Send the fault reset sequence."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
self.worker.do_reset_faults()
|
||||
|
||||
def on_query_remote_enable(self):
|
||||
"""Query the remote enable (LRE) state."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
self.worker.query_remote_enable()
|
||||
|
||||
def on_set_remote_enable(self, enable: bool):
|
||||
"""Set the remote enable (LRE) state."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
self.worker.set_remote_enable(enable)
|
||||
|
||||
def on_ler_reset(self):
|
||||
"""Send LER 0 only."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
self.worker.do_ler_reset()
|
||||
|
||||
def on_send_raw(self):
|
||||
"""Send the raw command from the terminal input."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return
|
||||
cmd = self.le_raw_cmd.text().strip()
|
||||
if not cmd:
|
||||
return
|
||||
self.worker.send_raw(cmd)
|
||||
|
||||
def on_raw_response(self, cmd: str, response: str):
|
||||
"""Display raw TX/RX pair in the terminal log."""
|
||||
self.text_terminal.append(f"TX: {cmd}")
|
||||
self.text_terminal.append(f"RX: {response if response else '<no response>'}")
|
||||
self.text_terminal.append("")
|
||||
self.text_terminal.verticalScrollBar().setValue(
|
||||
self.text_terminal.verticalScrollBar().maximum()
|
||||
)
|
||||
|
||||
def on_operation_complete(self, success: bool, message: str):
|
||||
"""Handle operation completion."""
|
||||
self.log_message(message)
|
||||
if not success:
|
||||
QMessageBox.warning(self, "Operation Failed", message)
|
||||
|
||||
def on_frequency_updated(self, freq: int):
|
||||
"""Update frequency display."""
|
||||
self.spin_frequency.setValue(freq)
|
||||
|
||||
def on_current_updated(self, current: int):
|
||||
"""Update current display."""
|
||||
self.spin_current.setValue(current)
|
||||
|
||||
def on_power_updated(self, power: float):
|
||||
"""Update power display."""
|
||||
self.lbl_power.setText(f"{power:.2f}")
|
||||
|
||||
def on_enabled_updated(self, enabled: bool):
|
||||
"""Update enabled status display."""
|
||||
state = "Enabled" if enabled else "Disabled"
|
||||
color = "green" if enabled else "red"
|
||||
self.lbl_enabled.setText(f"Status: {state}")
|
||||
self.lbl_enabled.setStyleSheet(f"color: {color}")
|
||||
|
||||
def on_serial_updated(self, controller_sn: str, head_sn: str):
|
||||
"""Update serial number displays."""
|
||||
self.lbl_controller_sn.setText(controller_sn)
|
||||
self.lbl_head_sn.setText(head_sn)
|
||||
|
||||
def on_status_registers_updated(self, ler, lce, cce):
|
||||
"""Update status register displays, interlock indicator, and decoded flags."""
|
||||
def _fmt(val):
|
||||
return str(val) if val is not None else "ERR"
|
||||
|
||||
self.lbl_ler.setText(_fmt(ler))
|
||||
self.lbl_lce.setText(_fmt(lce))
|
||||
self.lbl_cce.setText(_fmt(cce))
|
||||
|
||||
# Interlock fault: any non-zero register value indicates an active fault
|
||||
if any(v is None for v in (ler, lce, cce)):
|
||||
self.lbl_interlock.setText("UNKNOWN")
|
||||
self.lbl_interlock.setStyleSheet("color: gray")
|
||||
elif any(v != 0 for v in (ler, lce, cce)):
|
||||
self.lbl_interlock.setText("FAULT")
|
||||
self.lbl_interlock.setStyleSheet("color: red; font-weight: bold")
|
||||
else:
|
||||
self.lbl_interlock.setText("OK")
|
||||
self.lbl_interlock.setStyleSheet("color: green; font-weight: bold")
|
||||
|
||||
# Decode and display individual flags
|
||||
lines = []
|
||||
for reg_name, value, flags_dict in (
|
||||
("LER", ler, _LER_FLAGS),
|
||||
("LCE", lce, _LCE_FLAGS),
|
||||
("CCE", cce, _CCE_FLAGS),
|
||||
):
|
||||
if value is None:
|
||||
lines.append(f"{reg_name}: <read error>")
|
||||
continue
|
||||
active = _decode_register(flags_dict, value)
|
||||
if not active:
|
||||
lines.append(f"{reg_name} (raw={value}): OK — no flags set")
|
||||
else:
|
||||
lines.append(f"{reg_name} (raw={value}):")
|
||||
for bit, sev, desc, comment in active:
|
||||
label = _SEVERITY_LABEL.get(sev, '[ ]')
|
||||
lines.append(f" {label} bit {bit:2d} ({1 << bit:>5}): {desc}")
|
||||
lines.append(f" → {comment}")
|
||||
self.text_register_decode.setPlainText("\n".join(lines))
|
||||
|
||||
def on_remote_enable_updated(self, state):
|
||||
"""Update remote enable (LRE) indicator."""
|
||||
if state is None:
|
||||
self.lbl_lre.setText("UNKNOWN")
|
||||
self.lbl_lre.setStyleSheet("color: gray")
|
||||
elif state:
|
||||
self.lbl_lre.setText("ACTIVE")
|
||||
self.lbl_lre.setStyleSheet("color: green; font-weight: bold")
|
||||
else:
|
||||
self.lbl_lre.setText("INACTIVE")
|
||||
self.lbl_lre.setStyleSheet("color: red; font-weight: bold")
|
||||
|
||||
def log_message(self, message: str):
|
||||
"""Add message to log."""
|
||||
self.text_log.append(message)
|
||||
# Auto-scroll to bottom
|
||||
self.text_log.verticalScrollBar().setValue(
|
||||
self.text_log.verticalScrollBar().maximum()
|
||||
)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle application close."""
|
||||
if self.laser.is_connected:
|
||||
self.disconnect_laser()
|
||||
if self.worker_thread and self.worker_thread.isRunning():
|
||||
self.worker_thread.quit()
|
||||
self.worker_thread.wait()
|
||||
event.accept()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the application."""
|
||||
app = QApplication(sys.argv)
|
||||
window = HeliosTestApp()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Session summary — scan timeout & geometry fixes
|
||||
|
||||
## Bug fixed: oscilloscope timeout on first row
|
||||
|
||||
**File:** `hardware/tektronix_base.py` — `transfer_fastframe()` (~line 1414)
|
||||
|
||||
The scope was configured for N frames but only triggered on fewer (e.g. 57 of 58) because
|
||||
the stage decelerates before the last laser pulse. `transfer_fastframe` looped using
|
||||
`get_fastframe_count()` (the configured maximum), so the final `read_raw()` call blocked
|
||||
waiting for data that never came and timed out.
|
||||
|
||||
**Fix:** replaced `get_fastframe_count()` with a live query:
|
||||
```python
|
||||
acquired = int(self.query("ACQuire:NUMFRAMESACQuired?"))
|
||||
```
|
||||
The loop now reads exactly as many frames as the scope actually captured.
|
||||
|
||||
---
|
||||
|
||||
## Scan geometry overhaul
|
||||
|
||||
**File:** `sc3_aui_app.py`
|
||||
|
||||
### Constants added (near line 71)
|
||||
```python
|
||||
LASER_FREQ_HZ = 2000.0 # fixed laser pulse frequency
|
||||
SCAN_RAMP_MM = v² / (2a) # ≈ 3.33 mm (100² / 2×1500)
|
||||
```
|
||||
`SCAN_RAMP_MM` is the distance the stage needs to accelerate from rest to full scan
|
||||
velocity, or decelerate back to rest.
|
||||
|
||||
### `_run_scan()` changes
|
||||
- `points_per_row` now uses `LASER_FREQ_HZ` instead of the param-supplied `laser_freq`.
|
||||
- Pre-scan X position is `x_start - SCAN_RAMP_MM` so the stage arrives at `x_start`
|
||||
already at full velocity (TRIGOUT_MAXV fires at the right place).
|
||||
- Scan move ends at `x_start + x_delta + SCAN_RAMP_MM` so the stage doesn't begin
|
||||
decelerating until after the last data point.
|
||||
|
||||
### Travel-limit guards added (before any hardware interaction)
|
||||
Raises `ValueError` with an actionable message if the extended move would exceed the
|
||||
stage limits baked into the driver (`bbd20x.py`: X 0–110 mm, Y 0–75 mm):
|
||||
- `x_start - SCAN_RAMP_MM < 0`
|
||||
- `x_start + x_delta + SCAN_RAMP_MM > 110`
|
||||
- any Y row position outside 0–75 mm
|
||||
|
||||
Error messages tell the user exactly how many mm to adjust.
|
||||
|
||||
---
|
||||
|
||||
## What to check / next steps
|
||||
- Verify `ACQuire:NUMFRAMESACQuired?` is the correct query string for the specific scope
|
||||
model in use (MDO/MSO series assumed; confirm against programmer manual).
|
||||
- `LASER_FREQ_HZ = 2000.0` is a temporary constant — wire it to the UI param when ready.
|
||||
- Confirm `SCAN_RAMP_MM` matches observed stage behaviour; if the BBD202 velocity profile
|
||||
is not perfectly triangular the empirical ramp may differ slightly from v²/2a.
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Motion Controller Worker Thread
|
||||
|
||||
Handles all motion control operations in a separate thread to keep the UI responsive.
|
||||
Provides async command queueing and position updates via Qt signals.
|
||||
"""
|
||||
|
||||
from PyQt6 import QtCore
|
||||
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y
|
||||
import queue
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class MotionCommand:
|
||||
"""Represents a motion command"""
|
||||
def __init__(self, cmd_type: str, **kwargs):
|
||||
self.cmd_type = cmd_type
|
||||
self.params = kwargs
|
||||
|
||||
|
||||
class MotionWorker(QtCore.QObject):
|
||||
"""
|
||||
Worker object for handling motion control in a separate thread.
|
||||
|
||||
Signals:
|
||||
connected: Emitted when controller connects successfully
|
||||
disconnected: Emitted when controller disconnects
|
||||
connection_failed: Emitted when connection fails (error_msg: str)
|
||||
position_updated: Emitted when position changes (x: float, y: float)
|
||||
homed_status: Emitted with home status (x_homed: bool, y_homed: bool)
|
||||
move_completed: Emitted when a move completes (axis: str)
|
||||
error_occurred: Emitted when an error occurs (error_msg: str)
|
||||
"""
|
||||
|
||||
# Signals
|
||||
connected = QtCore.pyqtSignal()
|
||||
disconnected = QtCore.pyqtSignal()
|
||||
connection_failed = QtCore.pyqtSignal(str)
|
||||
position_updated = QtCore.pyqtSignal(float, float) # x, y in mm
|
||||
homed_status = QtCore.pyqtSignal(bool, bool) # x_homed, y_homed
|
||||
motion_status = QtCore.pyqtSignal(bool, bool) # x_moving, y_moving
|
||||
move_completed = QtCore.pyqtSignal(str) # axis name
|
||||
error_occurred = QtCore.pyqtSignal(str) # error message
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.controller: Optional[ThorlabsServoDriver] = None
|
||||
self.is_connected = False
|
||||
self.command_queue = queue.Queue()
|
||||
self.running = True
|
||||
|
||||
# Default parameters
|
||||
self.jog_speed = 20.0 # mm/s
|
||||
self.acceleration = 50.0 # mm/s^2
|
||||
self.step_size = 1.0 # mm
|
||||
|
||||
# Position tracking
|
||||
self.last_x = None
|
||||
self.last_y = None
|
||||
|
||||
# Status tracking
|
||||
self.last_x_homed = None
|
||||
self.last_y_homed = None
|
||||
self.last_x_moving = None
|
||||
self.last_y_moving = None
|
||||
|
||||
# Position update throttling
|
||||
self.last_position_update_time = 0
|
||||
self.position_update_interval = 0.2 # seconds between position reads
|
||||
|
||||
# Flag to pause polling during scanning (scan worker handles its own position queries)
|
||||
self.scanning_active = False
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def run(self):
|
||||
"""Main worker loop - processes commands from queue"""
|
||||
print("Motion worker thread started")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Check for commands with timeout to allow periodic position updates
|
||||
try:
|
||||
cmd = self.command_queue.get(timeout=0.05) # 50ms timeout
|
||||
self.process_command(cmd)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Periodically update position and status if connected
|
||||
# Skip updates during scanning - scan worker handles its own position queries
|
||||
if self.is_connected and self.controller and not self.scanning_active:
|
||||
self.update_position()
|
||||
self.update_home_status()
|
||||
self.update_motion_status()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in motion worker loop: {e}")
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
# Cleanup on exit
|
||||
if self.controller:
|
||||
try:
|
||||
self.controller.disconnect()
|
||||
except:
|
||||
pass
|
||||
|
||||
print("Motion worker thread stopped")
|
||||
|
||||
def process_command(self, cmd: MotionCommand):
|
||||
"""Process a motion command"""
|
||||
try:
|
||||
if cmd.cmd_type == 'connect':
|
||||
self.do_connect()
|
||||
elif cmd.cmd_type == 'disconnect':
|
||||
self.do_disconnect()
|
||||
elif cmd.cmd_type == 'jog':
|
||||
self.do_jog(cmd.params['axis'], cmd.params['direction'])
|
||||
elif cmd.cmd_type == 'home':
|
||||
self.do_home(cmd.params['axis'])
|
||||
elif cmd.cmd_type == 'set_velocity':
|
||||
self.do_set_velocity(cmd.params['speed'], cmd.params['accel'])
|
||||
elif cmd.cmd_type == 'set_step_size':
|
||||
self.step_size = cmd.params['step_size']
|
||||
elif cmd.cmd_type == 'set_axis_enable':
|
||||
self.do_set_axis_enable(cmd.params['axis'], cmd.params['enabled'])
|
||||
elif cmd.cmd_type == 'stop':
|
||||
self.running = False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing command {cmd.cmd_type}: {e}")
|
||||
self.error_occurred.emit(f"Command '{cmd.cmd_type}' failed: {str(e)}")
|
||||
|
||||
def do_connect(self):
|
||||
"""Connect to the motion controller"""
|
||||
try:
|
||||
self.controller = ThorlabsServoDriver()
|
||||
self.controller.connect()
|
||||
|
||||
# Enable channels
|
||||
self.controller.enable_axis(AXIS_X)
|
||||
self.controller.enable_axis(AXIS_Y)
|
||||
|
||||
# Start polling to populate cached state (positions, homed, moving, errors)
|
||||
self.controller.start_polling(interval=0.2)
|
||||
|
||||
# Wait for first polling cycle to populate status
|
||||
time.sleep(0.3)
|
||||
|
||||
# Set initial velocity parameters
|
||||
for dest in [AXIS_X, AXIS_Y]:
|
||||
self.controller.set_velocity_params(
|
||||
dest,
|
||||
max_velocity=self.jog_speed,
|
||||
acceleration=self.acceleration
|
||||
)
|
||||
|
||||
self.is_connected = True
|
||||
# Force initial updates (they will be emitted because last values are None)
|
||||
self.update_position()
|
||||
self.update_home_status()
|
||||
self.update_motion_status()
|
||||
self.connected.emit()
|
||||
|
||||
print("Motion controller connected successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to connect to motion controller: {e}")
|
||||
self.connection_failed.emit(str(e))
|
||||
|
||||
def do_disconnect(self):
|
||||
"""Disconnect from the motion controller"""
|
||||
if self.controller:
|
||||
try:
|
||||
self.controller.disconnect()
|
||||
print("Motion controller disconnected")
|
||||
except Exception as e:
|
||||
print(f"Error during disconnect: {e}")
|
||||
|
||||
self.controller = None
|
||||
self.is_connected = False
|
||||
self.disconnected.emit()
|
||||
|
||||
def do_jog(self, axis: str, direction: int):
|
||||
"""Execute a jog move"""
|
||||
if not self.is_connected or not self.controller:
|
||||
return
|
||||
|
||||
try:
|
||||
dest = AXIS_X if axis == 'x' else AXIS_Y
|
||||
|
||||
# Calculate relative distance
|
||||
distance = self.step_size * direction
|
||||
|
||||
# Execute the move (blocking, with short timeout for continuous jogging)
|
||||
self.controller.move_axis_relative(dest, distance, timeout=0.5)
|
||||
|
||||
# Update position
|
||||
self.update_position()
|
||||
|
||||
self.move_completed.emit(axis)
|
||||
|
||||
except TimeoutError:
|
||||
# Timeout is expected during continuous jog - don't report as error
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Jog error: {e}")
|
||||
self.error_occurred.emit(f"Jog failed: {str(e)}")
|
||||
|
||||
def do_home(self, axis: str):
|
||||
"""Home an axis"""
|
||||
if not self.is_connected or not self.controller:
|
||||
return
|
||||
|
||||
try:
|
||||
dest = AXIS_X if axis == 'x' else AXIS_Y
|
||||
|
||||
print(f"Homing {axis.upper()} axis...")
|
||||
self.controller.home_axis(dest, timeout=60.0)
|
||||
|
||||
# Update position and status after homing
|
||||
self.update_position()
|
||||
self.update_home_status()
|
||||
|
||||
print(f"{axis.upper()} axis homed successfully")
|
||||
|
||||
except TimeoutError:
|
||||
print(f"Home timeout: {axis.upper()} axis")
|
||||
self.error_occurred.emit(f"Homing {axis.upper()} timed out")
|
||||
except Exception as e:
|
||||
print(f"Home error: {e}")
|
||||
self.error_occurred.emit(f"Homing {axis.upper()} failed: {str(e)}")
|
||||
|
||||
def do_set_velocity(self, speed: float, accel: float):
|
||||
"""Set velocity parameters"""
|
||||
if not self.is_connected or not self.controller:
|
||||
self.jog_speed = speed
|
||||
self.acceleration = accel
|
||||
return
|
||||
|
||||
try:
|
||||
self.jog_speed = speed
|
||||
self.acceleration = accel
|
||||
|
||||
for dest in [AXIS_X, AXIS_Y]:
|
||||
self.controller.set_velocity_params(
|
||||
dest,
|
||||
max_velocity=self.jog_speed,
|
||||
acceleration=self.acceleration
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Set velocity error: {e}")
|
||||
|
||||
def do_set_axis_enable(self, axis: str, enabled: bool):
|
||||
"""Enable or disable an axis for manual movement"""
|
||||
if not self.is_connected or not self.controller:
|
||||
return
|
||||
|
||||
try:
|
||||
dest = AXIS_X if axis == 'x' else AXIS_Y
|
||||
if enabled:
|
||||
self.controller.enable_axis(dest)
|
||||
else:
|
||||
self.controller.disable_axis(dest)
|
||||
state_str = "enabled" if enabled else "disabled"
|
||||
print(f"{axis.upper()} axis {state_str}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Set axis enable error: {e}")
|
||||
self.error_occurred.emit(f"Failed to {'enable' if enabled else 'disable'} {axis.upper()} axis: {str(e)}")
|
||||
|
||||
def update_position(self):
|
||||
"""Update current position and emit signal if changed"""
|
||||
if not self.is_connected or not self.controller:
|
||||
return
|
||||
|
||||
# Throttle position reads to avoid excessive signal emission
|
||||
current_time = time.time()
|
||||
if current_time - self.last_position_update_time < self.position_update_interval:
|
||||
return
|
||||
self.last_position_update_time = current_time
|
||||
|
||||
try:
|
||||
# Read cached positions (populated by polling worker)
|
||||
x_pos = self.controller.positions[0]
|
||||
y_pos = self.controller.positions[1]
|
||||
|
||||
# Always emit on first update, or if position changed significantly (> 0.001mm)
|
||||
if (self.last_x is None or self.last_y is None or
|
||||
abs(x_pos - self.last_x) > 0.001 or abs(y_pos - self.last_y) > 0.001):
|
||||
self.last_x = x_pos
|
||||
self.last_y = y_pos
|
||||
print(f"Position update: X={x_pos:.3f}mm, Y={y_pos:.3f}mm")
|
||||
self.position_updated.emit(x_pos, y_pos)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating position: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def update_home_status(self):
|
||||
"""Update home status and emit signal if changed"""
|
||||
if not self.is_connected or not self.controller:
|
||||
return
|
||||
|
||||
try:
|
||||
x_homed = self.controller.am_homed[0]
|
||||
y_homed = self.controller.am_homed[1]
|
||||
|
||||
# Only emit if status changed
|
||||
if x_homed != self.last_x_homed or y_homed != self.last_y_homed:
|
||||
self.last_x_homed = x_homed
|
||||
self.last_y_homed = y_homed
|
||||
self.homed_status.emit(x_homed, y_homed)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating home status: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def update_motion_status(self):
|
||||
"""Update motion status and emit signal if changed.
|
||||
|
||||
The new driver's polling worker keeps am_moving[], am_error[]
|
||||
up to date automatically via status update messages.
|
||||
"""
|
||||
if not self.is_connected or not self.controller:
|
||||
return
|
||||
|
||||
try:
|
||||
# Check for any error conditions
|
||||
if self.controller.am_error[0]:
|
||||
self.error_occurred.emit("X-axis error detected")
|
||||
if self.controller.am_error[1]:
|
||||
self.error_occurred.emit("Y-axis error detected")
|
||||
|
||||
# Read cached motion status (updated by polling worker)
|
||||
x_moving = self.controller.am_moving[0]
|
||||
y_moving = self.controller.am_moving[1]
|
||||
|
||||
# Only emit if status changed
|
||||
if x_moving != self.last_x_moving or y_moving != self.last_y_moving:
|
||||
self.last_x_moving = x_moving
|
||||
self.last_y_moving = y_moving
|
||||
self.motion_status.emit(x_moving, y_moving)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating motion status: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Slot methods for queuing commands
|
||||
@QtCore.pyqtSlot()
|
||||
def queue_connect(self):
|
||||
"""Queue a connect command"""
|
||||
self.command_queue.put(MotionCommand('connect'))
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def queue_disconnect(self):
|
||||
"""Queue a disconnect command"""
|
||||
self.command_queue.put(MotionCommand('disconnect'))
|
||||
|
||||
@QtCore.pyqtSlot(str, int)
|
||||
def queue_jog(self, axis: str, direction: int):
|
||||
"""Queue a jog command"""
|
||||
self.command_queue.put(MotionCommand('jog', axis=axis, direction=direction))
|
||||
|
||||
@QtCore.pyqtSlot(str)
|
||||
def queue_home(self, axis: str):
|
||||
"""Queue a home command"""
|
||||
self.command_queue.put(MotionCommand('home', axis=axis))
|
||||
|
||||
@QtCore.pyqtSlot(float, float)
|
||||
def queue_set_velocity(self, speed: float, accel: float):
|
||||
"""Queue a set velocity command"""
|
||||
self.command_queue.put(MotionCommand('set_velocity', speed=speed, accel=accel))
|
||||
|
||||
@QtCore.pyqtSlot(float)
|
||||
def queue_set_step_size(self, step_size: float):
|
||||
"""Queue a set step size command"""
|
||||
self.command_queue.put(MotionCommand('set_step_size', step_size=step_size))
|
||||
|
||||
@QtCore.pyqtSlot(str, bool)
|
||||
def queue_set_axis_enable(self, axis: str, enabled: bool):
|
||||
"""Queue a command to enable or disable an axis"""
|
||||
self.command_queue.put(MotionCommand('set_axis_enable', axis=axis, enabled=enabled))
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def stop(self):
|
||||
"""Stop the worker thread"""
|
||||
# Set running to False immediately so the main loop can exit
|
||||
# even if it's blocked waiting for a response from the controller
|
||||
self.running = False
|
||||
# Also queue a stop command to ensure the command_queue.get() returns
|
||||
self.command_queue.put(MotionCommand('stop'))
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Launcher script for Helios laser test application
|
||||
|
||||
source /opt/srasenv/bin/activate
|
||||
cd /opt/scanengine-3
|
||||
python3 helios_test_app.py
|
||||
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>654</width>
|
||||
<height>754</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="uc480_display_area" native="true">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>640</width>
|
||||
<height>640</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>640</width>
|
||||
<height>640</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Exposure:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSlider" name="uc480_exposure_slider">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Gain:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSlider" name="uc480_gain_slider">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="uc480_close_window_btn">
|
||||
<property name="text">
|
||||
<string>Close Window</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
+963
@@ -0,0 +1,963 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1016</width>
|
||||
<height>1184</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed Medium</family>
|
||||
<pointsize>36</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Scanengine3 - AUI</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>16</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Geometry Data</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="3" column="3">
|
||||
<widget class="QLabel" name="label_26">
|
||||
<property name="text">
|
||||
<string>Scan Save Directory:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLineEdit" name="y_start_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="x_start_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>YS:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="4">
|
||||
<widget class="QLineEdit" name="y_delta_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>YD:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>XD:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QLineEdit" name="num_angles_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="label_25">
|
||||
<property name="text">
|
||||
<string>Scan File Prefix:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>NumAngles:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>XS:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1" colspan="2">
|
||||
<widget class="QLineEdit" name="scan_prefix_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>350</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<widget class="QLineEdit" name="row_spacing_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>RowSpacing:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="4">
|
||||
<widget class="QLineEdit" name="x_delta_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<spacer name="horizontalSpacer_8">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="4" colspan="2">
|
||||
<widget class="QLineEdit" name="scan_save_dir_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>350</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="6">
|
||||
<widget class="QPushButton" name="save_dir_browse_btn">
|
||||
<property name="text">
|
||||
<string>Browse</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="5">
|
||||
<spacer name="horizontalSpacer_7">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_16">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>16</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Communication Settings:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="1" column="2">
|
||||
<widget class="QPushButton" name="bbd202_connect_toggle">
|
||||
<property name="text">
|
||||
<string>Connect</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="t3r_comport_edit"/>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="bbd202_comport_edit"/>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QPushButton" name="oscope_connect_toggle">
|
||||
<property name="text">
|
||||
<string>Connect</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_9">
|
||||
<property name="text">
|
||||
<string>T3R Port:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="t3r_connect_toggle">
|
||||
<property name="text">
|
||||
<string>Connect</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_10">
|
||||
<property name="text">
|
||||
<string>BBD202 Port:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_22">
|
||||
<property name="text">
|
||||
<string>Oscilloscope IP Address:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QLineEdit" name="oscope_ip_edit"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_17">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>16</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>T3RSL Manual Controls:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<spacer name="horizontalSpacer_5">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="t3r_enable_t1_btn">
|
||||
<property name="text">
|
||||
<string>Enable T1</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||
<widget class="QPushButton" name="t3r_enable_t2_btn">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Enable T2</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QPushButton" name="t3r_enable_t3_btn">
|
||||
<property name="text">
|
||||
<string>Enable T3</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="4">
|
||||
<spacer name="horizontalSpacer_6">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="label_12">
|
||||
<property name="text">
|
||||
<string>T1 Axis</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_13">
|
||||
<property name="text">
|
||||
<string>T2 Axis</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QLabel" name="label_14">
|
||||
<property name="text">
|
||||
<string>T3 Axis</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="jog_t1_up_btn">
|
||||
<property name="text">
|
||||
<string>Up</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||
<widget class="QPushButton" name="jog_t2_up_btn">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Up</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="jog_t3_up_btn">
|
||||
<property name="text">
|
||||
<string>Up</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_20">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Jog Speed:</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="t3r_jog_speed_edit">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>75</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QPushButton" name="jog_t1_down_btn">
|
||||
<property name="text">
|
||||
<string>Down</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||
<widget class="QPushButton" name="jog_t2_down_btn">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Down</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="3">
|
||||
<widget class="QPushButton" name="jog_t3_down_btn">
|
||||
<property name="text">
|
||||
<string>Down</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1">
|
||||
<widget class="QPushButton" name="jog_gr_ccw_btn">
|
||||
<property name="text">
|
||||
<string>CCW</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="QLabel" name="label_15">
|
||||
<property name="text">
|
||||
<string>GR Axis</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="3">
|
||||
<widget class="QPushButton" name="jog_gr_cw_btn">
|
||||
<property name="text">
|
||||
<string>CW</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="4">
|
||||
<widget class="QPushButton" name="t3r_enable_gr_btn">
|
||||
<property name="text">
|
||||
<string>Enable GR</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_t3r_current">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_t3r_cur_header">
|
||||
<property name="text">
|
||||
<string>Motor Current (mA):</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_t3r_cur_t1">
|
||||
<property name="text">
|
||||
<string>T1</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QSpinBox" name="t3r_current_t1_spin">
|
||||
<property name="minimum">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>2000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>600</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QPushButton" name="t3r_set_current_t1_btn">
|
||||
<property name="text">
|
||||
<string>Set T1</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="label_t3r_cur_t2">
|
||||
<property name="text">
|
||||
<string>T2</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QSpinBox" name="t3r_current_t2_spin">
|
||||
<property name="minimum">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>2000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>600</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QPushButton" name="t3r_set_current_t2_btn">
|
||||
<property name="text">
|
||||
<string>Set T2</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_t3r_cur_t3">
|
||||
<property name="text">
|
||||
<string>T3</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QSpinBox" name="t3r_current_t3_spin">
|
||||
<property name="minimum">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>2000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>600</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QPushButton" name="t3r_set_current_t3_btn">
|
||||
<property name="text">
|
||||
<string>Set T3</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QLabel" name="label_t3r_cur_gr">
|
||||
<property name="text">
|
||||
<string>GR</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QSpinBox" name="t3r_current_gr_spin">
|
||||
<property name="minimum">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>2000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>600</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="3">
|
||||
<widget class="QPushButton" name="t3r_set_current_gr_btn">
|
||||
<property name="text">
|
||||
<string>Set GR</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_18">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>16</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>MLS203-1 Manual Controls:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="0" column="4">
|
||||
<widget class="QPushButton" name="bbd_enable_all_btn">
|
||||
<property name="text">
|
||||
<string>Toggle Axes Enable</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_19">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Jog Speed:</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="lineEdit_10">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>75</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="4" column="4" alignment="Qt::AlignmentFlag::AlignLeft">
|
||||
<widget class="QLabel" name="bbd_current_y_position_indicator">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>24</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>000.00</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1" colspan="3">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="bbd_home_all_btn">
|
||||
<property name="text">
|
||||
<string>Home All Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||
<widget class="QPushButton" name="bbd_jog_y_pos_btn">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Y+</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="QLabel" name="label_21">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>X Position:</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1" alignment="Qt::AlignmentFlag::AlignLeft">
|
||||
<widget class="QLabel" name="bbd_current_x_position_indicator">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>24</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>000.00</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="bbd_jog_x_neg_btn">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>X-</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||
<widget class="QPushButton" name="bbd_jog_y_neg_btn">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Y-</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="bbd_jog_x_pos_btn">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>X+</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="3" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="QLabel" name="label_23">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Y Position:</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QPushButton" name="bbd_set_current_start_btn">
|
||||
<property name="text">
|
||||
<string>Set Current Coords as Start Coords</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="4">
|
||||
<widget class="QPushButton" name="bbd_set_delta_current_btn">
|
||||
<property name="text">
|
||||
<string>Calculate Delta (Current - Start)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="show_camera_toggle">
|
||||
<property name="text">
|
||||
<string>Show Camera Window</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="start_scan_btn">
|
||||
<property name="text">
|
||||
<string>Start Scanning</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<tabstops>
|
||||
<tabstop>x_start_edit</tabstop>
|
||||
<tabstop>y_start_edit</tabstop>
|
||||
<tabstop>x_delta_edit</tabstop>
|
||||
<tabstop>y_delta_edit</tabstop>
|
||||
<tabstop>num_angles_edit</tabstop>
|
||||
<tabstop>row_spacing_edit</tabstop>
|
||||
<tabstop>scan_prefix_edit</tabstop>
|
||||
<tabstop>scan_save_dir_edit</tabstop>
|
||||
<tabstop>save_dir_browse_btn</tabstop>
|
||||
<tabstop>t3r_comport_edit</tabstop>
|
||||
<tabstop>t3r_connect_toggle</tabstop>
|
||||
<tabstop>bbd202_comport_edit</tabstop>
|
||||
<tabstop>bbd202_connect_toggle</tabstop>
|
||||
<tabstop>oscope_ip_edit</tabstop>
|
||||
<tabstop>oscope_connect_toggle</tabstop>
|
||||
<tabstop>t3r_enable_t1_btn</tabstop>
|
||||
<tabstop>t3r_enable_t2_btn</tabstop>
|
||||
<tabstop>t3r_enable_t3_btn</tabstop>
|
||||
<tabstop>jog_t1_up_btn</tabstop>
|
||||
<tabstop>jog_t2_up_btn</tabstop>
|
||||
<tabstop>jog_t3_up_btn</tabstop>
|
||||
<tabstop>t3r_jog_speed_edit</tabstop>
|
||||
<tabstop>jog_t1_down_btn</tabstop>
|
||||
<tabstop>jog_t2_down_btn</tabstop>
|
||||
<tabstop>jog_t3_down_btn</tabstop>
|
||||
<tabstop>jog_gr_ccw_btn</tabstop>
|
||||
<tabstop>jog_gr_cw_btn</tabstop>
|
||||
<tabstop>t3r_enable_gr_btn</tabstop>
|
||||
<tabstop>t3r_current_t1_spin</tabstop>
|
||||
<tabstop>t3r_set_current_t1_btn</tabstop>
|
||||
<tabstop>t3r_current_t2_spin</tabstop>
|
||||
<tabstop>t3r_set_current_t2_btn</tabstop>
|
||||
<tabstop>t3r_current_t3_spin</tabstop>
|
||||
<tabstop>t3r_set_current_t3_btn</tabstop>
|
||||
<tabstop>t3r_current_gr_spin</tabstop>
|
||||
<tabstop>t3r_set_current_gr_btn</tabstop>
|
||||
<tabstop>bbd_enable_all_btn</tabstop>
|
||||
<tabstop>lineEdit_10</tabstop>
|
||||
<tabstop>bbd_home_all_btn</tabstop>
|
||||
<tabstop>bbd_jog_y_pos_btn</tabstop>
|
||||
<tabstop>bbd_jog_x_neg_btn</tabstop>
|
||||
<tabstop>bbd_jog_y_neg_btn</tabstop>
|
||||
<tabstop>bbd_jog_x_pos_btn</tabstop>
|
||||
<tabstop>bbd_set_current_start_btn</tabstop>
|
||||
<tabstop>bbd_set_delta_current_btn</tabstop>
|
||||
<tabstop>show_camera_toggle</tabstop>
|
||||
<tabstop>start_scan_btn</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,153 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>867</width>
|
||||
<height>388</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>48</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>SCANNING</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>14</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Current Scan Progress:</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="current_scan_current_row_indicator">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>12</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Row 0 of 999</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QProgressBar" name="current_scan_progbar">
|
||||
<property name="value">
|
||||
<number>24</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>14</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Overall Scan Progress:</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="overall_scan_current_angle_indicator">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed SemiBold</family>
|
||||
<pointsize>12</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Angle 0 of 18</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QProgressBar" name="overall_scan_progbar">
|
||||
<property name="value">
|
||||
<number>24</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="abort_btn">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed ExtraBold</family>
|
||||
<pointsize>36</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>ABORT</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
+2635
File diff suppressed because it is too large
Load Diff
Executable
+1500
File diff suppressed because it is too large
Load Diff
+213
@@ -0,0 +1,213 @@
|
||||
# SRAS Scan Binary Format — Version 4
|
||||
|
||||
Each `.sras` file contains **one complete scan**: all GR rotation angles and all
|
||||
Y rows. Files are named `{prefix}.sras`.
|
||||
|
||||
---
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
[Global Header — 43 bytes]
|
||||
[Angle Table — n_angles × 4 bytes (float32 per angle)]
|
||||
[Row Table — n_rows × 4 bytes (float32 per row)]
|
||||
[Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)]
|
||||
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
|
||||
[Waveform Data — n_angles × n_rows × n_channels × n_frames × samples_per_frame × bps bytes]
|
||||
```
|
||||
|
||||
All multi-byte integers and floats use **big-endian** byte order
|
||||
(`>` in Python's `struct` module).
|
||||
|
||||
---
|
||||
|
||||
## Global Header (42 bytes)
|
||||
|
||||
| Offset | Size | Type | Field | Description |
|
||||
|--------|------|-----------|--------------------|--------------------------------------------------|
|
||||
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
|
||||
| 4 | 1 | `uint8` | `version` | Format version — `4` |
|
||||
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
|
||||
| 7 | 2 | `uint16` | `n_rows` | Number of Y rows per angle |
|
||||
| 9 | 4 | `float32` | `x_start_mm` | X scan start position in mm |
|
||||
| 13 | 4 | `float32` | `x_delta_mm` | X scan width in mm |
|
||||
| 17 | 4 | `float32` | `velocity_mm_s` | Stage scan velocity in mm/s |
|
||||
| 21 | 4 | `float32` | `laser_freq_hz` | Laser repetition rate in Hz |
|
||||
| 25 | 4 | `uint32` | `n_frames` | A-scans per row (= FastFrame count per channel) |
|
||||
| 29 | 4 | `uint32` | `samples_per_frame`| Time samples per waveform |
|
||||
| 33 | 8 | `float64` | `sample_rate_hz` | Oscilloscope sample rate in Hz (e.g. 6.25e9) |
|
||||
| 41 | 1 | `uint8` | `bytes_per_sample` | Bytes per ADC sample: `1` = int8, `2` = int16 |
|
||||
| 42 | 1 | `uint8` | `n_channels` | Number of channels recorded (currently `3`) |
|
||||
|
||||
**Total header size:** 43 bytes — verified:
|
||||
`struct.calcsize(">4sBHHffffIIdBB") == 43`.
|
||||
|
||||
---
|
||||
|
||||
## Angle Table
|
||||
|
||||
Immediately after the header: **n_angles** big-endian float32 values, one per
|
||||
GR angle (degrees, 0–180).
|
||||
|
||||
```
|
||||
angle[0], angle[1], …, angle[n_angles - 1]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Row Table
|
||||
|
||||
Immediately after the angle table: **n_rows** big-endian float32 values, one
|
||||
per Y row (mm).
|
||||
|
||||
```
|
||||
y_mm[0], y_mm[1], …, y_mm[n_rows - 1]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Preamble Blocks
|
||||
|
||||
Immediately after the row table: **n_channels** length-prefixed UTF-8 strings,
|
||||
one per channel in `SCAN_CHANNELS` order (CH1, CH3, CH4). Each block is:
|
||||
|
||||
```
|
||||
uint16 length — byte length of the following UTF-8 string
|
||||
bytes preamble — WFMOutpre response string from the oscilloscope
|
||||
```
|
||||
|
||||
The preamble captures per-channel scaling constants (YMULT, YOFF, YZERO) needed
|
||||
to convert raw ADC values to volts.
|
||||
|
||||
---
|
||||
|
||||
## Background Block
|
||||
|
||||
Immediately after the preamble blocks: a single CH1 waveform captured with the
|
||||
**Helios (generation) laser enabled** and the **Genesis (detection) laser
|
||||
disabled**. This provides a noise/background reference for subtraction during
|
||||
post-processing.
|
||||
|
||||
```
|
||||
uint32 n_bg_samples — number of samples in the background waveform
|
||||
int8[] bg_data — raw ADC samples (same encoding as waveform data)
|
||||
```
|
||||
|
||||
`n_bg_samples` equals `samples_per_frame` under normal acquisition settings.
|
||||
|
||||
---
|
||||
|
||||
## Waveform Data
|
||||
|
||||
Immediately after the background block. Data is stored in **angle-major, row-minor**
|
||||
order. Within each row, channels are interleaved in ascending channel-index
|
||||
order, with each channel's FastFrame data written in frame order.
|
||||
|
||||
```
|
||||
for angle in 0 … n_angles-1:
|
||||
for row in 0 … n_rows-1:
|
||||
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
|
||||
for frame in 0 … n_frames-1:
|
||||
samples[0 … samples_per_frame-1] # bps bytes each
|
||||
```
|
||||
|
||||
Each sample is a raw signed ADC value. With `bytes_per_sample = 1` this is
|
||||
**int8** (−128 … +127). With `bytes_per_sample = 2` this is **big-endian
|
||||
int16**.
|
||||
|
||||
Total data size:
|
||||
```
|
||||
n_angles × n_rows × 3 × n_frames × samples_per_frame × bytes_per_sample
|
||||
```
|
||||
|
||||
> **Incomplete files:** If a scan is aborted the file is closed immediately and
|
||||
> the data block will be shorter than the expected size. Readers should check
|
||||
> `file_size >= header + angle_table + row_table + data` before reshaping.
|
||||
|
||||
---
|
||||
|
||||
## Spatial Mapping
|
||||
|
||||
The *k*-th waveform (frame) in a row corresponds to the *k*-th laser pulse that
|
||||
hit the sample. The physical X position of that pulse is:
|
||||
|
||||
```
|
||||
x_k = x_start_mm + k * (velocity_mm_s / laser_freq_hz)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Python Read Example
|
||||
|
||||
```python
|
||||
import struct, numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
HDR_FMT = ">4sBHHffffIIdBB"
|
||||
HDR_SIZE = struct.calcsize(HDR_FMT) # 43 bytes
|
||||
|
||||
def read_sras(path):
|
||||
with open(path, "rb") as f:
|
||||
hdr = struct.unpack(HDR_FMT, f.read(HDR_SIZE))
|
||||
magic, ver, n_angles, n_rows, xs, xd, vel, freq, nf, spf, sr, bps, n_ch = hdr
|
||||
assert magic == b"SRAS" and ver == 4, "Not a v4 SRAS file"
|
||||
|
||||
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4")
|
||||
y_positions = np.frombuffer(f.read(n_rows * 4), dtype=">f4")
|
||||
|
||||
# Preamble blocks (one per channel)
|
||||
preambles = []
|
||||
for _ in range(n_ch):
|
||||
(plen,) = struct.unpack(">H", f.read(2))
|
||||
preambles.append(f.read(plen).decode("utf-8"))
|
||||
|
||||
# Background waveform block (v4+)
|
||||
(n_bg,) = struct.unpack(">I", f.read(4))
|
||||
background = np.frombuffer(f.read(n_bg), dtype=np.int8)
|
||||
|
||||
dtype = np.int8 if bps == 1 else ">i2"
|
||||
data = np.frombuffer(f.read(), dtype=dtype).reshape(
|
||||
n_angles, n_rows, n_ch, nf, spf
|
||||
)
|
||||
|
||||
return {
|
||||
"angles_deg": angles,
|
||||
"y_positions_mm": y_positions,
|
||||
"x_start_mm": xs,
|
||||
"x_delta_mm": xd,
|
||||
"velocity_mm_s": vel,
|
||||
"laser_freq_hz": freq,
|
||||
"sample_rate_hz": sr,
|
||||
"n_channels": n_ch, # 3: CH1, CH3, CH4 (see Acquisition Settings)
|
||||
"preambles": preambles, # WFMOutpre strings, same order as n_channels
|
||||
"background": background,# shape: (n_bg_samples,) — CH1 noise reference
|
||||
# shape: (n_angles, n_rows, n_channels, n_frames, samples_per_frame)
|
||||
"data": data,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acquisition Settings (fixed by sc3_aui_app.py)
|
||||
|
||||
| Parameter | Value |
|
||||
|----------------------|------------------------------|
|
||||
| Oscilloscope trigger | CH2, rising edge, 1.24 V |
|
||||
| Trigger offset | 0 % (trigger at left edge) |
|
||||
| Sample rate | 6.25 GS/s (160 ps/sample) |
|
||||
| Channels recorded | CH1, CH3, CH4 |
|
||||
| Stage X velocity | 100 mm/s |
|
||||
| Stage X acceleration | 1500 mm/s² |
|
||||
| Stage X trigger out | Logic-high at max velocity |
|
||||
| Acquisition mode | FastFrame, Normal trigger |
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Change |
|
||||
|---------|--------|
|
||||
| 1 | One file per row; header included `angle_idx`, `row_idx`, `angle_deg`, `y_mm`. |
|
||||
| 2 | One file per scan; global header with `n_angles`/`n_rows`; separate angle and row tables; three channels (CH1, CH3, CH4) per row. |
|
||||
| 3 | Added preamble blocks (WFMOutpre strings) after the row table, one length-prefixed UTF-8 block per channel. |
|
||||
| 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. |
|
||||
+1961
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
PyQt6==6.10.2
|
||||
numpy==2.4.1
|
||||
matplotlib==3.10.8
|
||||
+1321
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user