diff --git a/.gitignore b/.gitignore index 4775c73..9f93b76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ *.venv +.venv/ *.pyc __pycache__/ +.pytest_cache/ +*.egg-info/ .env diff --git a/README.md b/README.md index db94d5b..62e5d3e 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,99 @@ -# RadioPhotonic PCB — Laser Controller +# RadioPhotonic PCB PC Software -GUI application and embeddable Python module for controlling a dual-laser board -over UART (115 200 baud). Designed to run on a Raspberry Pi or any Linux machine. +PyQt6-приложение для управления лазерной платой по UART. +Вся рабочая логика сосредоточена в пакете `laser_control`; старый FreeSimpleGUI и legacy-модули удалены. ---- +## Структура -## Project structure - -``` +```text . -├── _device_main.py # GUI application entry point -├── gui.py # FreeSimpleGUI layout definition -├── device_interaction.py # High-level device commands (legacy) -├── device_commands.py # Low-level protocol helpers (legacy) -├── device_conversion.py # Physical-unit conversion formulas (legacy) -│ -├── laser_control/ # Standalone embeddable module -│ ├── __init__.py # Public API -│ ├── controller.py # LaserController class -│ ├── protocol.py # Command encoding / response decoding -│ ├── validators.py # Input validation -│ ├── conversions.py # Physical-unit conversions -│ ├── models.py # Dataclasses (Measurements, DeviceStatus, …) -│ ├── constants.py # Protocol constants and physical limits -│ ├── exceptions.py # Exception hierarchy -│ └── example_usage.py # Usage examples -│ -├── tests/ # pytest test suite (75 tests) -│ ├── conftest.py -│ ├── test_validation.py -│ ├── test_protocol.py -│ └── test_integration.py -│ -├── pyproject.toml # Package metadata (laser_control) -├── run # Launch script for the GUI app -└── deploy # First-time environment setup script +├── _device_main.py +├── run +├── run_device_main.bat +├── requirements.txt +├── laser_control/ +│ ├── __init__.py +│ ├── constants.py +│ ├── controller.py +│ ├── conversions.py +│ ├── exceptions.py +│ ├── models.py +│ ├── protocol.py +│ ├── transport.py +│ ├── validators.py +│ ├── example_usage.py +│ └── gui/ +│ ├── main.py +│ ├── theme.py +│ ├── window.py +│ ├── sections.py +│ └── worker.py ``` ---- +## Что поддерживается -## Setting up the virtual environment +- ручной режим: `T1/T2/I1/I2` +- live telemetry: `T1/T2`, внешние термисторы, фотодиоды, `3V3/5V1/5V2/7V0` +- AD9102: saw/SRAM режимы и загрузка custom waveform +- AD9833, DS1809 и STM32 DAC через отдельные firmware-команды +- сохранение профиля на SD-карту устройства +- сброс платы командой `DEFAULT_ENABLE` -### First-time setup +Не поддерживается и удалено из PC-кода: + +- legacy-команды `0x3333` и `0x5555` +- старый flow про `saved data` и `remove file` +- task/sweep-режим как публичный сценарий + +## Установка ```bash -# 1. Create virtual environment python3 -m venv .venv - -# 2. Activate it source .venv/bin/activate - -# 3. Install GUI and serial dependencies pip install -r requirements.txt - -# 4. Install laser_control as an editable package -# (required for imports to work in any subdirectory) -pip install -e . - -# 5. Install pytest (for running tests) -pip install pytest ``` - -### Every subsequent session - -```bash -source .venv/bin/activate -``` - ---- - -## Running the GUI application +## Запуск GUI ```bash source .venv/bin/activate ./run -# or directly: -python3 _device_main.py ``` -The application auto-detects the USB serial port. If more than one port is -present, the first one found is used. - ---- - -## Running the tests +или ```bash -source .venv/bin/activate -python3 -m pytest tests/ -v +python3 -m laser_control.gui.main ``` -Expected result: **75 passed**. +Автоподключение использует первый доступный USB UART-порт. +При подключении приложение только читает текущее состояние и телеметрию, без автоприменения ручных параметров. +Совместимый launcher `_device_main.py` сохранён, но он только проксирует запуск в новый PyQt entrypoint. ---- - -## Running the usage example - -```bash -source .venv/bin/activate - -# Auto-detect port: -python3 laser_control/example_usage.py - -# Specify port explicitly: -python3 laser_control/example_usage.py /dev/ttyUSB0 -``` - ---- - -## Embedding laser_control in another application - -After `pip install -e .` (or copying the `laser_control/` folder into your -project and running `pip install -e .` there), import as follows: +## Публичный API ```python from laser_control import ( LaserController, - VariationType, + Measurements, + DeviceStatus, + DeviceState, ValidationError, CommunicationError, ) +``` -# --- Manual mode --- -with LaserController(port='/dev/ttyUSB0') as ctrl: - try: - ctrl.set_manual_mode( - temp1=25.0, # °C [15 … 40] - temp2=30.0, # °C [15 … 40] - current1=40.0, # mA [15 … 60] - current2=35.0, # mA [15 … 60] - ) - data = ctrl.get_measurements() - if data: - print(f"3.3 V rail: {data.voltage_3v3:.3f} V") - print(f"Laser 1 temperature: {data.temp1:.2f} °C") - except ValidationError as e: - print(f"Bad parameter: {e}") - except CommunicationError as e: - print(f"Device not responding: {e}") +## Пример встраивания +```python +from laser_control import LaserController -# --- Current variation mode --- -def on_data(m): - print(f"I1={m.current1:.3f} mA T1={m.temp1:.2f} °C") - -with LaserController(port=None, on_data=on_data) as ctrl: # port=None → auto-detect - ctrl.start_variation( - variation_type=VariationType.CHANGE_CURRENT_LD1, - params={ - 'min_value': 20.0, # mA - 'max_value': 50.0, # mA - 'step': 0.5, # mA [0.002 … 0.5] - 'time_step': 50, # µs [20 … 100] - 'delay_time': 5, # ms [3 … 10] - 'static_temp1': 25.0, - 'static_temp2': 30.0, - 'static_current1': 35.0, - 'static_current2': 35.0, - } +with LaserController(port="/dev/ttyUSB0") as controller: + controller.set_manual_mode( + temp1=25.0, + temp2=30.0, + current1=40.0, + current2=35.0, ) - import time; time.sleep(2) - ctrl.stop_task() + print(controller.get_measurements()) ``` - -### Parameter limits - -| Parameter | Min | Max | Unit | -|---|---|---|---| -| Temperature (T1, T2) | 15.0 | 40.0 | °C | -| Current (I1, I2) | 15.0 | 60.0 | mA | -| Current variation step | 0.002 | 0.5 | mA | -| Temperature variation step | 0.05 | 1.0 | °C | -| Time step | 20 | 100 | µs | -| Delay time | 3 | 10 | ms | - -### Exception hierarchy - -``` -LaserControlError -├── ValidationError -│ ├── TemperatureOutOfRangeError -│ ├── CurrentOutOfRangeError -│ └── InvalidParameterError -├── CommunicationError -│ ├── PortNotFoundError -│ ├── DeviceNotRespondingError -│ ├── CRCError -│ └── ProtocolError -└── DeviceError - ├── DeviceOverheatingError - ├── PowerSupplyError - └── DeviceStateError -``` - ---- - -## Device output - -Each measurement response contains: - -| Field | Description | Unit | -|---|---|---| -| `temp1`, `temp2` | Laser temperatures | °C | -| `temp_ext1`, `temp_ext2` | External thermistor temperatures | °C | -| `current1`, `current2` | Photodiode currents | mA | -| `voltage_3v3` | 3.3 V power rail | V | -| `voltage_5v1`, `voltage_5v2` | 5 V power rails | V | -| `voltage_7v0` | 7 V power rail | V | diff --git a/_device_main.py b/_device_main.py index 4fa229e..35f2455 100644 --- a/_device_main.py +++ b/_device_main.py @@ -1,470 +1,7 @@ -from FreeSimpleGUI import TIMEOUT_KEY, WIN_CLOSED -import json -import math -import socket -import subprocess +"""Compatibility launcher for the PyQt GUI.""" -import device_interaction as dev +from laser_control.gui.main import main -import gui - -use_client = False - -sending_param = {} - -#### ---- Constants - -GUI_TIMEOUT_INTERVAL = 5#505 - dev.WAIT_AFTER_SEND*1000 # GUI refresh time in milliseconds - -SAVE_POINTS_NUMBER = 1000 # Number of most recent data points kept in memory - -INITIAL_TEMPERATURE_1 = 28 # Set initial temperature for Laser 1 in Celsius: from -1 to 45 C ?? -INITIAL_TEMPERATURE_2 = 28.9 # Set initial temperature for Laser 2 in Celsius: from -1 to 45 C ?? -INITIAL_CURRENT_1 = 33 # 64.0879 max # Set initial current for Laser 1, in mA -INITIAL_CURRENT_2 = 35 # 64.0879 max # Set initial current for Laser 2, in mA - -#### ---- Functions - -def start_task(prt): - global sending_param - dev.send_task_command(prt, sending_param) - -def stop_task(prt): - global sending_param - sending_param = {} - dev.reset_port_settings(prt) - dev.send_control_parameters(prt, params) - -def get_float(values, strId): - value = 0.0 - try: - value = float(values[strId]) - except: - value = float("nan") - window['-StartCycle-'].update(disabled = True) - return value - -def shorten(i): - return "{:.2f}".format(round(i, 2)) - - -def set_initial_params(): - params = {} - params['Temp_1'] = INITIAL_TEMPERATURE_1 # Initial temperature for Laser 1 - params['Temp_2'] = INITIAL_TEMPERATURE_2 # Initial temperature for Laser 2 - params['ProportionalCoeff_1'] = int(10*256) # Proportional coefficient for temperature stabilizatoin for Laser 1 <-- ToDo (why int?) - params['ProportionalCoeff_2'] = int(10*256) # Proportional coefficient for temperature stabilizatoin for Laser 2 <-- ToDo (why int?) - params['IntegralCoeff_1'] = int(0.5*256) # Integral coefficient for temperature stabilizatoin for Laser 1 <-- ToDo (why int?) - params['IntegralCoeff_2'] = int(0.5*256) # Integral coefficient for temperature stabilizatoin for Laser 2 <-- ToDo (why int?) - params['Message_ID'] = "00FF" # Send Message ID (hex format) - params['Iset_1'] = INITIAL_CURRENT_1 # Currency value array for Laser 1, in mA - params['Iset_2'] = INITIAL_CURRENT_2 # Currency value array for Laser 2, in mA - params['Min_Temp_1'] = INITIAL_TEMPERATURE_1 - params['Max_Temp_1'] = 28 - params['Min_Current_1'] = INITIAL_CURRENT_1 - params['Max_Current_1'] = 70.0 #50 - params['Delta_Temp_1'] = 0.05 - params['Delta_Current_1'] = 0.05 - params['Min_Temp_2'] = INITIAL_TEMPERATURE_2 - params['Max_Temp_2'] = 28 - params['Min_Current_2'] = INITIAL_CURRENT_2 - params['Max_Current_2'] = 60 # 50 - params['Delta_Temp_2'] = 0.05 - params['Delta_Current_2'] = 0.05 - params['Delta_Time'] = 50 - params['Tau'] = 10 - return params - -def update_data_lists(): - saved_data.append(data) - if len(saved_data)>SAVE_POINTS_NUMBER: - saved_data.pop(0) - - draw_data.append(data) - if len(draw_data)>gui.GRAPH_POINTS_NUMBER: - draw_data.pop(0) - - - -######## ---- Main program if __name__ == "__main__": - saved_data = [] - draw_data = [] - - params = set_initial_params() - - prt = dev.create_port_connection() - if prt is None: - print('Can\'t create connection. Closing program...') - exit(1) - - # dev.request_state(prt) - dev.send_control_parameters(prt, params) - saved_data.append(dev.request_data(prt)) - draw_data.append(saved_data[0]) - - window = gui.setup_gui(params) - axes_signs = gui.sign_axes(window) - - current_and_temperature_settings_available = True - disableStartButton = False - - if use_client: - p = subprocess.Popen("path/to/oscilloscope.exe") - sck = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sck.bind(("127.0.0.1", 9090)) - sck.listen() - conn, _ = sck.accept() - - while True: - event, values = window.read(timeout=GUI_TIMEOUT_INTERVAL) - - enable_manual_settings = window['-EnableManualSettings-'].get() - if current_and_temperature_settings_available: - window['-EnableT1-'].update(disabled = enable_manual_settings) - window['-EnableT2-'].update(disabled = enable_manual_settings) - window['-EnableC1-'].update(disabled = enable_manual_settings) - window['-EnableC2-'].update(disabled = enable_manual_settings) - - window['-InputMinT1-'].update(disabled = enable_manual_settings) - window['-InputMaxT1-'].update(disabled = enable_manual_settings) - window['-InputDeltaT1-'].update(disabled = enable_manual_settings) - window['-InputMinT2-'].update(disabled = enable_manual_settings) - window['-InputMaxT2-'].update(disabled = enable_manual_settings) - window['-InputDeltaT2-'].update(disabled = enable_manual_settings) - window['-InputMinC1-'].update(disabled = enable_manual_settings) - window['-InputMaxC1-'].update(disabled = enable_manual_settings) - window['-InputDeltaC1-'].update(disabled = enable_manual_settings) - window['-InputMinC2-'].update(disabled = enable_manual_settings) - window['-InputMaxC2-'].update(disabled = enable_manual_settings) - window['-InputDeltaC2-'].update(disabled = enable_manual_settings) - - window['-InputT1-'].update(disabled = not enable_manual_settings) - window['-InputT2-'].update(disabled = not enable_manual_settings) - window['-InputI1-'].update(disabled = not enable_manual_settings) - window['-InputI2-'].update(disabled = not enable_manual_settings) - - window['-StartCycle-'].update(disabled = not enable_manual_settings) - - if current_and_temperature_settings_available and not enable_manual_settings: - enable_t1 = window['-EnableT1-'].get() - enable_t2 = window['-EnableT2-'].get() - enable_c1 = window['-EnableC1-'].get() - enable_c2 = window['-EnableC2-'].get() - - sending_param['ProportionalCoeff_1'] = params['ProportionalCoeff_1'] - sending_param['IntegralCoeff_1'] = params['IntegralCoeff_1'] - sending_param['ProportionalCoeff_2'] = params['ProportionalCoeff_2'] - sending_param['IntegralCoeff_2'] = params['IntegralCoeff_2'] - - if enable_t1 and \ - not enable_t2 and \ - not enable_c1 and \ - not enable_c2: - sending_param['TaskType'] = dev.cmd.TaskType.ChangeTemperatureLD1 - sending_param['MinT1'] = get_float(values, '-InputMinT1-') - sending_param['MaxT1'] = get_float(values, '-InputMaxT1-') - sending_param['DeltaT1'] = get_float(values, '-InputDeltaT1-') - sending_param['I1'] = get_float(values, '-InputI1-') - sending_param['I2'] = get_float(values, '-InputI2-') - sending_param['T2'] = get_float(values, '-InputT2-') - sending_param['Dt'] = get_float(values ,'-InputDeltaTime-') - sending_param['Tau'] = get_float(values ,'-InputTau-') - - disableStartButton = math.isnan(sending_param['MinT1']) or \ - math.isnan(sending_param['MaxT1']) or \ - math.isnan(sending_param['DeltaT1']) or \ - math.isnan(sending_param['I1']) or \ - math.isnan(sending_param['I2']) or \ - math.isnan(sending_param['T2']) or \ - math.isnan(sending_param['Dt']) or \ - math.isnan(sending_param['Tau']) - - window['-EnableT2-'].update(disabled = enable_t1) - window['-EnableC1-'].update(disabled = enable_t1) - window['-EnableC2-'].update(disabled = enable_t1) - enable_t2 = window['-EnableT2-'].get() - enable_c1 = window['-EnableC1-'].get() - enable_c2 = window['-EnableC2-'].get() - window['-InputMinT1-'].update(disabled = not enable_t1) - window['-InputMaxT1-'].update(disabled = not enable_t1) - window['-InputDeltaT1-'].update(disabled = not enable_t1) - window['-InputI1-'].update(disabled = not enable_t1) - window['-InputI2-'].update(disabled = not enable_t1) - window['-InputT2-'].update(disabled = not enable_t1) - window['-InputMinT2-'].update(disabled = enable_t1) - window['-InputMaxT2-'].update(disabled = enable_t1) - window['-InputDeltaT2-'].update(disabled = enable_t1) - window['-InputMinC1-'].update(disabled = enable_t1) - window['-InputMaxC1-'].update(disabled = enable_t1) - window['-InputDeltaC1-'].update(disabled = enable_t1) - window['-InputMinC2-'].update(disabled = enable_t1) - window['-InputMaxC2-'].update(disabled = enable_t1) - window['-InputDeltaC2-'].update(disabled = enable_t1) - window['-EnableManualSettings-'].update(disabled = True) - elif enable_t2 and \ - not enable_t1 and \ - not enable_c1 and \ - not enable_c2: - sending_param['TaskType'] = dev.cmd.TaskType.ChangeTemperatureLD2 - sending_param['MinT2'] = get_float(values, '-InputMinT2-') - sending_param['MaxT2'] = get_float(values, '-InputMaxT2-') - sending_param['DeltaT2'] = get_float(values, '-InputDeltaT2-') - sending_param['I1'] = get_float(values, '-InputI1-') - sending_param['I2'] = get_float(values, '-InputI2-') - sending_param['T1'] = get_float(values, '-InputT1-') - sending_param['Dt'] = get_float(values ,'-InputDeltaTime-') - sending_param['Tau'] = get_float(values ,'-InputTau-') - disableStartButton = math.isnan(sending_param['MinT2']) or \ - math.isnan(sending_param['MaxT2']) or \ - math.isnan(sending_param['DeltaT2']) or \ - math.isnan(sending_param['I1']) or \ - math.isnan(sending_param['I2']) or \ - math.isnan(sending_param['T1']) or \ - math.isnan(sending_param['Dt']) or \ - math.isnan(sending_param['Tau']) - - window['-EnableT1-'].update(disabled = enable_t2) - window['-EnableC1-'].update(disabled = enable_t2) - window['-EnableC2-'].update(disabled = enable_t2) - enable_t1 = window['-EnableT1-'].get() - enable_c1 = window['-EnableC1-'].get() - enable_c2 = window['-EnableC2-'].get() - window['-InputMinT1-'].update(disabled = enable_t2) - window['-InputMaxT1-'].update(disabled = enable_t2) - window['-InputDeltaT1-'].update(disabled = enable_t2) - window['-InputT1-'].update(disabled = not enable_t2) - window['-InputI1-'].update(disabled = not enable_t2) - window['-InputI2-'].update(disabled = not enable_t2) - window['-InputMinT2-'].update(disabled = not enable_t2) - window['-InputMaxT2-'].update(disabled = not enable_t2) - window['-InputDeltaT2-'].update(disabled = not enable_t2) - window['-InputMinC1-'].update(disabled = enable_t2) - window['-InputMaxC1-'].update(disabled = enable_t2) - window['-InputDeltaC1-'].update(disabled = enable_t2) - window['-InputMinC2-'].update(disabled = enable_t2) - window['-InputMaxC2-'].update(disabled = enable_t2) - window['-InputDeltaC2-'].update(disabled = enable_t2) - window['-EnableManualSettings-'].update(disabled = True) - elif enable_c1 and \ - not enable_c2 and \ - not enable_t1 and \ - not enable_t2: - sending_param['TaskType'] = dev.cmd.TaskType.ChangeCurrentLD1 - sending_param['MinC1'] = get_float(values, '-InputMinC1-') - sending_param['MaxC1'] = get_float(values, '-InputMaxC1-') - sending_param['DeltaC1'] = get_float(values, '-InputDeltaC1-') - sending_param['T1'] = get_float(values, '-InputT1-') - sending_param['T2'] = get_float(values, '-InputT2-') - sending_param['I2'] = get_float(values, '-InputI2-') - sending_param['Dt'] = get_float(values ,'-InputDeltaTime-') - sending_param['Tau'] = get_float(values ,'-InputTau-') - disableStartButton = math.isnan(sending_param['MinC1']) or \ - math.isnan(sending_param['MaxC1']) or \ - math.isnan(sending_param['DeltaC1']) or \ - math.isnan(sending_param['T1']) or \ - math.isnan(sending_param['T2']) or \ - math.isnan(sending_param['I2']) or \ - math.isnan(sending_param['Dt']) or \ - math.isnan(sending_param['Tau']) - - window['-EnableT1-'].update(disabled = enable_c1) - window['-EnableT2-'].update(disabled = enable_c1) - window['-EnableC2-'].update(disabled = enable_c1) - enable_t1 = window['-EnableT1-'].get() - enable_t2 = window['-EnableT2-'].get() - enable_c2 = window['-EnableC2-'].get() - window['-InputMinT1-'].update(disabled = enable_c1) - window['-InputMaxT1-'].update(disabled = enable_c1) - window['-InputDeltaT1-'].update(disabled = enable_c1) - window['-InputT1-'].update(disabled = not enable_c1) - window['-InputT2-'].update(disabled = not enable_c1) - window['-InputI2-'].update(disabled = not enable_c1) - window['-InputMinT2-'].update(disabled = enable_c1) - window['-InputMaxT2-'].update(disabled = enable_c1) - window['-InputDeltaT2-'].update(disabled = enable_c1) - window['-InputMinC1-'].update(disabled = not enable_c1) - window['-InputMaxC1-'].update(disabled = not enable_c1) - window['-InputDeltaC1-'].update(disabled = not enable_c1) - window['-InputMinC2-'].update(disabled = enable_c1) - window['-InputMaxC2-'].update(disabled = enable_c1) - window['-InputDeltaC2-'].update(disabled = enable_c1) - window['-EnableManualSettings-'].update(disabled = True) - elif enable_c2 and \ - not enable_c1 and \ - not enable_t1 and \ - not enable_t2: - sending_param['TaskType'] = dev.cmd.TaskType.ChangeCurrentLD2 - sending_param['MinC2'] = get_float(values, '-InputMinC2-') - sending_param['MaxC2'] = get_float(values, '-InputMaxC2-') - sending_param['DeltaC2'] = get_float(values, '-InputDeltaC2-') - sending_param['T1'] = get_float(values, '-InputT1-') - sending_param['T2'] = get_float(values, '-InputT2-') - sending_param['I1'] = get_float(values, '-InputI1-') - sending_param['Dt'] = get_float(values ,'-InputDeltaTime-') - sending_param['Tau'] = get_float(values ,'-InputTau-') - disableStartButton = math.isnan(sending_param['MinC2']) or \ - math.isnan(sending_param['MaxC2']) or \ - math.isnan(sending_param['DeltaC2']) or \ - math.isnan(sending_param['T1']) or \ - math.isnan(sending_param['T2']) or \ - math.isnan(sending_param['I1']) or \ - math.isnan(sending_param['Dt']) or \ - math.isnan(sending_param['Tau']) - - window['-EnableT1-'].update(disabled = enable_c2) - window['-EnableT2-'].update(disabled = enable_c2) - window['-EnableC1-'].update(disabled = enable_c2) - enable_t1 = window['-EnableT1-'].get() - enable_t2 = window['-EnableT2-'].get() - enable_c1 = window['-EnableC1-'].get() - window['-InputMinT1-'].update(disabled = enable_c2) - window['-InputMaxT1-'].update(disabled = enable_c2) - window['-InputDeltaT1-'].update(disabled = enable_c2) - window['-InputI1-'].update(disabled = not enable_c2) - window['-InputT1-'].update(disabled = not enable_c2) - window['-InputT2-'].update(disabled = not enable_c2) - window['-InputMinT2-'].update(disabled = enable_c2) - window['-InputMaxT2-'].update(disabled = enable_c2) - window['-InputDeltaT2-'].update(disabled = enable_c2) - window['-InputMinC1-'].update(disabled = enable_c2) - window['-InputMaxC1-'].update(disabled = enable_c2) - window['-InputDeltaC1-'].update(disabled = enable_c2) - window['-InputMinC2-'].update(disabled = not enable_c2) - window['-InputMaxC2-'].update(disabled = not enable_c2) - window['-InputDeltaC2-'].update(disabled = not enable_c2) - window['-EnableManualSettings-'].update(disabled = True) - elif not enable_t1 and \ - not enable_t2 and \ - not enable_c1 and \ - not enable_c2: - sending_param = {} - - window['-EnableT1-'].update(disabled = False) - window['-EnableT2-'].update(disabled = False) - window['-EnableC1-'].update(disabled = False) - window['-EnableC2-'].update(disabled = False) - window['-InputMinT1-'].update(disabled = True) - window['-InputMaxT1-'].update(disabled = True) - window['-InputDeltaT1-'].update(disabled = True) - window['-InputMinT2-'].update(disabled = True) - window['-InputMaxT2-'].update(disabled = True) - window['-InputDeltaT2-'].update(disabled = True) - window['-InputMinC1-'].update(disabled = True) - window['-InputMaxC1-'].update(disabled = True) - window['-InputDeltaC1-'].update(disabled = True) - window['-InputMinC2-'].update(disabled = True) - window['-InputMaxC2-'].update(disabled = True) - window['-InputDeltaC2-'].update(disabled = True) - window['-InputT1-'].update(disabled = True) - window['-InputT2-'].update(disabled = True) - window['-InputI1-'].update(disabled = True) - window['-InputI2-'].update(disabled = True) - window['-EnableManualSettings-'].update(disabled = False) - - window['-InputDeltaTime-'].update(disabled = not enable_c1 and not enable_t1 and not enable_c2 and not enable_t2) - window['-InputTau-'].update(disabled = not enable_c1 and not enable_t1 and not enable_c2 and not enable_t2) - - window['-StartCycle-'].update(disabled = not enable_c1 and not enable_t1 and not enable_c2 and not enable_t2 or disableStartButton) - - if event == WIN_CLOSED or event == '-EXIT-': - if use_client: - p.terminate() - conn.close() - sck.close() - dev.reset_port_settings(prt) - break - - elif event == '-StartCycle-': - if not enable_manual_settings: - window['-StopCycle-'].update(disabled = False) - window['-StartCycle-'].update(disabled = True) - window['-EnableT1-'].update(disabled = True) - window['-EnableC1-'].update(disabled = True) - window['-EnableT1-'].update(False) - window['-EnableC1-'].update(False) - window['-EnableT2-'].update(disabled = True) - window['-EnableC2-'].update(disabled = True) - window['-EnableT2-'].update(False) - window['-EnableC2-'].update(False) - window['-InputMinT1-'].update(disabled = True) - window['-InputMaxT1-'].update(disabled = True) - window['-InputDeltaT1-'].update(disabled = True) - window['-InputMinT2-'].update(disabled = True) - window['-InputMaxT2-'].update(disabled = True) - window['-InputDeltaT2-'].update(disabled = True) - window['-InputMinC1-'].update(disabled = True) - window['-InputMaxC1-'].update(disabled = True) - window['-InputDeltaC1-'].update(disabled = True) - window['-InputMinC2-'].update(disabled = True) - window['-InputMaxC2-'].update(disabled = True) - window['-InputDeltaC2-'].update(disabled = True) - window['-InputDeltaTime-'].update(disabled = True) - window['-InputTau-'].update(disabled = True) - window['-InputT1-'].update(disabled = True) - window['-InputT2-'].update(disabled = True) - window['-InputI1-'].update(disabled = True) - window['-InputI2-'].update(disabled = True) - current_and_temperature_settings_available = False - # TODO get task parameters from gui and put its to params - - if use_client: - jsondoc_str = json.dumps(sending_param) - jsondoc = bytearray() - jsondoc.extend(jsondoc_str.encode()) - conn.sendall(jsondoc) - - start_task(prt) - else: - params['Temp_1'] = float(values['-InputT1-']) - params['Temp_2'] = float(values['-InputT2-']) - params['Iset_1'] = float(values['-InputI1-']) - params['Iset_2'] = float(values['-InputI2-']) - dev.send_control_parameters(prt, params) - #print(sending_param) - elif event == '-StopCycle-': - window['-StopCycle-'].update(disabled = True) - current_and_temperature_settings_available = True - stop_task(prt) - elif event == TIMEOUT_KEY: - data = dev.request_data(prt) - - update_data_lists() - - window['-TOUT_1-'].update(gui.READ_TEMPERATURE_TEXT+' 1: '+shorten(data['Temp_1'])+' C') - window['-TOUT_2-'].update(gui.READ_TEMPERATURE_TEXT+' 2: '+shorten(data['Temp_2'])+' C') - window['-IOUT_1-'].update(gui.READ_CURRENT_TEXT+' 1: '+shorten(data['I1'])+' мА') - window['-IOUT_2-'].update(gui.READ_CURRENT_TEXT+' 2: '+shorten(data['I2'])+' мА') - window['-DateTime-'].update(data['datetime'].strftime('%d-%m-%Y %H:%M:%S:%f')[:-3]) - window['-TTerm1-'].update('T терм 1: '+shorten(data['Temp_Ext_1'])+' C') - window['-TTerm2-'].update('T терм 2: '+shorten(data['Temp_Ext_2'])+' C') - window['-I1_PANEL-'].update('I1: '+shorten(data['I1'])+' мА') - window['-I2_PANEL-'].update('I2: '+shorten(data['I2'])+' мА') - window['-3V3-'].update('3V3: '+shorten(data['MON_3V3'])+' В') - window['-5V1-'].update('5V1: '+shorten(data['MON_5V1'])+' В') - window['-5V2-'].update('5V2: '+shorten(data['MON_5V2'])+' В') - window['-7V0-'].update('7V0: '+shorten(data['MON_7V0'])+' В') - - window['-GraphT1-'].draw_line((len(draw_data)-1, draw_data[-2]['Temp_1']), (len(draw_data), draw_data[-1]['Temp_1']), color='yellow') - window['-GraphT2-'].draw_line((len(draw_data)-1, draw_data[-2]['Temp_2']), (len(draw_data), draw_data[-1]['Temp_2']), color='yellow') - window['-GraphI1-'].draw_line((len(draw_data)-1, draw_data[-2]['I1']), (len(draw_data), draw_data[-1]['I1']), color='yellow') - window['-GraphI2-'].draw_line((len(draw_data)-1, draw_data[-2]['I2']), (len(draw_data), draw_data[-1]['I2']), color='yellow') - - # When graphs reach end of X scale, start scrolling - if len(draw_data)>=gui.GRAPH_POINTS_NUMBER: - # Scroll graphs - window['-GraphT1-'].move(-1, 0) - window['-GraphT2-'].move(-1, 0) - window['-GraphI1-'].move(-1, 0) - window['-GraphI2-'].move(-1, 0) - - # Scroll back graphs' labels - for key, sgn in axes_signs.items(): - window[key].MoveFigure(sgn[0], 1, 0) - window[key].MoveFigure(sgn[1], 1, 0) - - window.close() - - dev.close_connection(prt) - + raise SystemExit(main()) diff --git a/deploy b/deploy deleted file mode 100755 index 41f574f..0000000 --- a/deploy +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/bash -sudo apt install python3-venv -python3 -m venv .venv -source .venv/bin/activate -pip install FreeSimpleGUI PySerial -deactivate diff --git a/device_commands.py b/device_commands.py deleted file mode 100644 index 741743a..0000000 --- a/device_commands.py +++ /dev/null @@ -1,347 +0,0 @@ -from enum import IntEnum -from serial import Serial -from serial.tools import list_ports -import device_conversion as cnv -from datetime import datetime - -#### ---- Constants - -GET_DATA_TOTAL_LENGTH = 30 # Total number of bytes when getting DATA -SEND_PARAMS_TOTAL_LENGTH = 30 # Total number of bytes when sending parameters -TASK_ENABLE_COMMAND_LENGTH = 32 # Total number of bytes when sending TASK_ENABLE command - -class TaskType(IntEnum): - Manual = 0x00 - ChangeCurrentLD1 = 0x01 - ChangeCurrentLD2 = 0x02 - ChangeTemperatureLD1 = 0x03 - ChangeTemperatureLD2 = 0x04 -#### ---- Auxiliary functions - -def int_to_hex(inp): - if inp<0 or inp>65535: - print("Error. Input should be within [0, 65535]. Returning N=0.") - return "0000" - return f"{inp:#0{6}x}"[2:] - -def crc(lst): - crc=int("0x"+lst[0],16) - for i in range(1,len(lst)): - crc=crc^int("0x"+lst[i],16) - return int_to_hex(crc) - -def show_hex_string(string): - return "".join("\\x{}".format(char.encode()) for char in (string[i:i+2] for i in range(0, len(string), 2))) - - -def flipfour(s): - ''' Changes "abcd" to "cdba" - ''' - if len(s) != 4: - print("Error. Trying to flip string with length not equal to 4.") - return None - return s[2:4]+s[0:2] - - -#### ---- Port Operations - - -def setup_port_connection(baudrate: int, port: str, timeout_sec: float): - prt = Serial() - prt.baudrate = baudrate - prt.port = port - prt.timeout = timeout_sec - return prt - - -def open_port(prt): - prt.open() - - if prt.is_open: - print("Connection succesful. Port is opened.") - print("Port parameters:", prt) - print("") - else: - print("Can't open port. Exiting program.") - exit() - - -def close_port(prt): - prt.close() - print("") - if prt.is_open: - print("Can't close port. Exiting program.") - exit() - else: - print("Port is closed. Exiting program.") - exit() - - -#### ---- Interacting with device: low-level - -# ---- Sending commands - -def send_TASK_ENABLE(prt, bytestring): - ''' Set task parameters (x7777 + ...). - Expected device answer: STATE. - ''' - if len(bytestring) != TASK_ENABLE_COMMAND_LENGTH: - print("Error. Wrong parameter string for TASK_ENABLE.") - return None - prt.write(bytestring) - print("Sent: Set control parameters (TASK_ENABLE).") - -def send_DECODE_ENABLE(prt, bytestring): - ''' Set control parameters (x1111 + ...). - Expected device answer: STATE. - ''' - if len(bytestring) != SEND_PARAMS_TOTAL_LENGTH: - print("Error. Wrong parameter string for DECODE_ENABLE.") - return None - prt.write(bytestring) - print("Sent: Set control parameters (DECODE_ENABLE).") - - -def send_DEFAULT_ENABLE(prt): - ''' Reset the device (x2222). - Expected device answer: STATE. - ''' - input = bytearray.fromhex(flipfour("2222")) - prt.write(input) - print("Sent: Reset device (DEFAULT_ENABLE).") - - -def send_TRANSS_ENABLE(prt): - ''' Request all saved data (x3333). - Expected device answer: SAVED_DATA. - ''' - # TODO later. - pass - - -def send_TRANS_ENABLE(prt): - ''' Request last piece of data (x4444). - Expected device answer: DATA. - ''' - input = bytearray.fromhex(flipfour("4444")) - prt.write(input) - print("Sent: Request last data (TRANS_ENABLE).") - - -def send_REMOVE_FILE(prt): - ''' Delete saved data (x5555). - Expected device answer: STATE. - ''' - input = bytearray.fromhex(flipfour("5555")) - prt.write(input) - print("Sent: Delete saved data (REMOVE_FILE).") - pass - - -def send_STATE(prt): - ''' Request state (x6666). - Expected device answer: STATE. - ''' - input = bytearray.fromhex(flipfour("6666")) - prt.write(input) - print("Sent: Request state (STATE).") - pass - - -# ---- Getting data - - -def get_STATE(prt): - ''' Get decoded state of the device in byte format (2 bytes). - ''' - - print("Received "+str(prt.inWaiting())+" bytes.") - if prt.inWaiting()!=2: - print("Error. Couldn't get STATE data. prt.inWaiting():", prt.inWaiting()) - print("Flushing input data:", prt.read(prt.inWaiting())) - # print("Flushing input data:", prt.read(2), prt.read(2)) - return None - - out_bytes = prt.read(2) - return out_bytes - - -def get_DATA(prt): - ''' Get decoded state of the device in byte format (426 bytes). - ''' - - print("Received "+str(prt.inWaiting())+" bytes.\n") - if prt.inWaiting()!=GET_DATA_TOTAL_LENGTH: - print("Error. Couldn't get DATA data.") - print("receiven data len:", prt.inWaiting()) - return None - - out_bytes = prt.read(GET_DATA_TOTAL_LENGTH) - return out_bytes - - -#### ---- Interacting with device: decode/encode messages - -# ---- Encoding functions -def CalculateCRC(data): - CRC_input = [] - for i in range(1,int(len(data)/4)): - CRC_input.append(data[4*i:4*i+4]) - return crc(CRC_input) - -def encode_Setup(): - bits=['0']*16 - - bits[15] = "1" # enable work - bits[14] = "1" # enable 5v1 - bits[13] = "1" # enable 5v2 - bits[12] = "1" # enable LD1 - bits[11] = "1" # enable LD2 - bits[10] = "1" # enable REF1 - bits[9] = "1" # enable REF2 - bits[8] = "1" # enable TEC1 - bits[7] = "1" # enable TEC2 - bits[6] = "1" # enable temp stab 1 - bits[5] = "1" # enable temp stab 2 - bits[4] = "0" # enable sd save - bits[3] = "1" # enable PI1 coef read - bits[2] = "1" # enable PI2 coef read - bits[1] = "0" # reserved - bits[0] = "0" # reserved - - s="".join([str(i) for i in bits]) - return hex(int(s,2))[2:] - -def create_TaskEnableCommand(sending_param): - data = flipfour("7777") # Word 0 - data += flipfour(encode_Setup()) # Word 1 - data += flipfour(int_to_hex(sending_param['TaskType'])) # Word 2 - match sending_param['TaskType']: - case TaskType.ChangeCurrentLD1.value: - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['MinC1']))) # Word 3 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['MaxC1']))) # Word 4 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['DeltaC1']))) # Word 5 - data += flipfour(int_to_hex(int(sending_param['Dt']*100))) # Word 6 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(sending_param['T1']))) # Word 7 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['I2']))) # Word 8 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(sending_param['T2']))) # Word 9 - case TaskType.ChangeCurrentLD2.value: - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['MinC2']))) # Word 3 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['MaxC2']))) # Word 4 - data += flipfour(int_to_hex(int(sending_param['DeltaC2']*100))) # Word 5 - data += flipfour(int_to_hex(int(sending_param['Dt']*100))) # Word 6 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(sending_param['T2']))) # Word 7 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['I1']))) # Word 8 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(sending_param['T1']))) # Word 9 - case TaskType.ChangeTemperatureLD1: - raise Exception("Temperature changing is not implemented yet") - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['MinT1']))) # Word 3 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['MaxT1']))) # Word 4 - data += flipfour(int_to_hex(sending_param['DeltaT1']*100)) # Word 5 - data += flipfour(int_to_hex(sending_param['Dt']*100)) # Word 6 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(sending_param['I1']))) # Word 7 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['I2']))) # Word 8 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(sending_param['T2']))) # Word 9 - case TaskType.ChangeTemperatureLD2: - raise Exception("Temperature changing is not implemented yet") - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['MinT2']))) # Word 3 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['MaxT2']))) # Word 4 - data += flipfour(int_to_hex(sending_param['DeltaT2']*100)) # Word 5 - data += flipfour(int_to_hex(sending_param['Dt']*100)) # Word 6 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(sending_param['I2']))) # Word 7 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(sending_param['I1']))) # Word 8 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(sending_param['T1']))) # Word 9 - case _: - raise Exception(f"Undefined TaskType:{sending_param['TaskType']}") - data += flipfour(int_to_hex(int(sending_param['Tau']))) # Word 10 - data += flipfour(int_to_hex(sending_param['ProportionalCoeff_1'])) # Word 11 - data += flipfour(int_to_hex(sending_param['IntegralCoeff_1'])) # Word 12 - data += flipfour(int_to_hex(sending_param['ProportionalCoeff_2'])) # Word 13 - data += flipfour(int_to_hex(sending_param['IntegralCoeff_2'])) # Word 14 - data += CalculateCRC(data) # Word 15 - - return bytearray.fromhex(data) - -def encode_Input(params): - - if params is None: - return bytearray.fromhex("1111"+"00"*14) - - data = flipfour("1111") # Word 0 - data += flipfour(encode_Setup()) # Word 1 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(params['Temp_1']))) # Word 2 - data += flipfour(int_to_hex(cnv.conv_T_C_to_N(params['Temp_2']))) # Word 3 - data += flipfour("0000")*3 # Words 4-6 - data += flipfour(int_to_hex(params['ProportionalCoeff_1'])) # Word 7 - data += flipfour(int_to_hex(params['IntegralCoeff_1'])) # Word 8 - data += flipfour(int_to_hex(params['ProportionalCoeff_2'])) # Word 9 - data += flipfour(int_to_hex(params['IntegralCoeff_2'])) # Word 10 - data += flipfour(params['Message_ID']) # Word 11 - - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(params['Iset_1']))) # Word 12 - data += flipfour(int_to_hex(cnv.conv_I_mA_to_N(params['Iset_2']))) # Word 13 - - CRC_input = [] - for i in range(1,int(len(data)/4)): - CRC_input.append(data[4*i:4*i+4]) - - CRC = crc(CRC_input) - data += CRC # Word 14 - - return bytearray.fromhex(data) - - -# ---- Decoding functions - -def decode_STATE(state): - st = flipfour(state) - if st == '0000': - status = "All ok." - elif st == '0001': - status = "SD Card reading/writing error (SD_ERR)." - elif st == '0002': - status = "Command error (UART_ERR)." - elif st == '0004': - status = "Wrong parameter value error (UART_DECODE_ERR)." - elif st == '0008': - status = "Laser 1: TEC driver overheat (TEC1_ERR)." - elif st == '0010': - status = "Laser 2: TEC driver overheat (TEC2_ERR)." - elif st == '0020': - status = "Resetting system error (DEFAULT_ERR)." - elif st == '0040': - status = "File deletion error (REMOVE_ERR)." - else: - status = "Unknown or reserved error." - return status - - -def decode_DATA(dh): - def get_word(s,num): - return flipfour(s[num*2*2:num*2*2+4]) - def get_int_word(s,num): - return int(get_word(s,num),16) - - data = {} - data['datetime'] = datetime.now() - data['Header'] = get_word(dh, 0) - data['I1'] = cnv.conv_I_N_to_mA(get_int_word(dh, 1)) #LD1_param.POWER - data['I2'] = cnv.conv_I_N_to_mA(get_int_word(dh, 2)) #LD2_param.POWER - data['TO_LSB'] = get_int_word(dh, 3) #TO6_counter_LSB - data['TO_MSB'] = get_int_word(dh, 4) #TO6_counter_MSB - data['Temp_1'] = cnv.conv_T_N_to_C(get_int_word(dh, 5)) #LD1_param.LD_CURR_TEMP - data['Temp_2'] = cnv.conv_T_N_to_C(get_int_word(dh, 6)) #LD2_param.LD_CURR_TEMP - data['Temp_Ext_1'] = cnv.conv_TExt_N_to_C(get_int_word(dh, 7)) #U_Rt1_ext_Gain - data['Temp_Ext_2'] = cnv.conv_TExt_N_to_C(get_int_word(dh, 8)) #U_Rt2_ext_Gain - data['MON_3V3'] = cnv.conv_U3V3_N_to_V(get_int_word(dh, 9)) #3V_monitor - data['MON_5V1'] = cnv.conv_U5V_N_to_V(get_int_word(dh, 10)) #5V1_monitor - data['MON_5V2'] = cnv.conv_U5V_N_to_V(get_int_word(dh, 11)) #5V2_monitor - data['MON_7V0'] = cnv.conv_U7V_N_to_V(get_int_word(dh, 12)) #7V_monitor - data['Message_ID'] = get_word(dh, 13) # Last received command - data['CRC'] = get_word(dh, 14) - - return data - - - - diff --git a/device_conversion.py b/device_conversion.py deleted file mode 100644 index 947b5a9..0000000 --- a/device_conversion.py +++ /dev/null @@ -1,68 +0,0 @@ - - -import math - -# ---- Conversion functions - -VREF = 2.5 # Volts - -R1 = 10000 # Ohm -R2 = 2200 # Ohm -R3 = 27000 # Ohm -R4 = 30000 # Ohm -R5 = 27000 # Ohm -R6 = 56000 # Ohm - -RREF = 10 # Ohm (current-setting resistor) @1550 nm - 28.7 Ohm; @840 nm - 10 Ohm - -R7 = 22000 # Ohm -R8 = 22000 # Ohm -R9 = 5100 # Ohm -R10 = 180000 # Ohm - -class Task: - def __init__(self): - self.task_type = 0 - # Here should be fields, contained task parameters - -def conv_T_C_to_N(T): - Rt = 10000 * math.exp( 3900/(T+273) - 3900/298 ) - U = VREF/(R5*(R3+R4)) * ( R1*R4*(R5+R6) - Rt*(R3*R6-R4*R5) ) / (Rt+R1) - N = int(U * 65535 / VREF) - if N<0 or N>65535: - print("Error converting T=" + str(T) + " to N=" + str(N) + ". N should be within [0, 65535]. Returning N=0.") - return N - -def conv_T_N_to_C(N): - U = N*VREF/65535 # Volts - Rt = R1 * (VREF*R4*(R5+R6) - U*R5*(R3+R4)) / (U*R5*(R3+R4) + VREF*R3*R6 - VREF*R4*R5) # Ohm - T = 1 / (1/298 + 1/3900 * math.log(Rt/10000)) - 273 # In Celsius - return T - -def conv_TExt_N_to_C(N): - U = N*VREF/4095*1/(1+100000/R10) + VREF*R9/(R8+R9) # Volts - Rt = R7*U/(VREF-U) # Ohm - T = 1 / (1/298 + 1/3455 * math.log(Rt/10000)) - 273 # In Celsius - return T - -def conv_I_mA_to_N(I): - N = int(65535/2000 * RREF * I) # I in mA - if N<0 or N>65535: - print("Error converting I=" + str(I) + " to N=" + str(N) + ". N should be within [0, 65535]. Returning N=0.") - N=0 - return N - -def conv_I_N_to_mA(N): - return N*2.5/(65535*4.4) - 1/20.4 # I in mA - - -def conv_U3V3_N_to_V(u_int): - return u_int * 1.221 * 0.001 # Volts - -def conv_U5V_N_to_V(u_int): - return u_int * 1.8315 * 0.001 # Volts - -def conv_U7V_N_to_V(u_int): - return u_int * 6.72 * 0.001 # Volts - - diff --git a/device_interaction.py b/device_interaction.py deleted file mode 100644 index 6e2cb78..0000000 --- a/device_interaction.py +++ /dev/null @@ -1,144 +0,0 @@ - - -import time - -from datetime import datetime -import device_commands as cmd - - -#### ---- Constants - -WAIT_AFTER_SEND = 0.15 # Wait after sending command, before requesting input (in seconds). - - -#### ---- High-level port commands -''' -def create_port_connection(): - prt = None - for port, _, _ in sorted(cmd.list_ports.comports()): - try: - prt = cmd.setup_port_connection(port=port, baudrate=115200, timeout_sec=1) - cmd.open_port(prt) - reset_port_settings(prt) - except: - prt.close() - continue - break - return prt -''' -def create_port_connection(): - prt = None - print() - ports = [] - for port, _,_ in sorted(cmd.list_ports.comports()): - ports.append(port) - -#ONLY FOR LINUX!!! - have_ttyUSB = False - USB_ports = [] - for port in ports: - if "USB" in port: - USB_ports.append(port) - if len(USB_ports): - ports = USB_ports -# print("ports:", ports) - - -# for port, _, _ in sorted(cmd.list_ports.comports()): - for port in ports: - try: - print("PORT:", port) - prt = cmd.setup_port_connection(port=port, baudrate=115200, timeout_sec=1) - cmd.open_port(prt) - reset_port_settings(prt) - except: - prt.close() - continue - break - return prt - - - - -# def setup_connection(): -# prt = cmd.setup_port_connection() -# cmd.open_port(prt) -# return prt - - -def reset_port_settings(prt): - # Reset port settings and check status - cmd.send_DEFAULT_ENABLE(prt) - time.sleep(WAIT_AFTER_SEND) - status = cmd.get_STATE(prt).hex() - if status is not None: - print("Received: STATE. State status:", cmd.decode_STATE(status), "("+cmd.flipfour(status)+")") - print("") - - -def request_state(prt): - # Request data - cmd.send_STATE(prt) - time.sleep(WAIT_AFTER_SEND) - status = cmd.get_STATE(prt).hex() - if status is not None: - print("Received: STATE. State status:", cmd.decode_STATE(status), "("+cmd.flipfour(status)+")") - print("") - - -def send_control_parameters(prt, params): - # Send control parameters - hexstring = cmd.encode_Input(params) - cmd.send_DECODE_ENABLE(prt,hexstring) - time.sleep(WAIT_AFTER_SEND) - status = cmd.get_STATE(prt).hex() - if status is not None: - print("Received: STATE. State status:", cmd.decode_STATE(status), "("+cmd.flipfour(status)+")") - print("") - else: - print("") - -def send_task_command(prt, sending_param): - # Send task command (TASK_ENABLE state in firmware) - hexstring = cmd.create_TaskEnableCommand(sending_param) - cmd.send_TASK_ENABLE(prt,hexstring) - time.sleep(WAIT_AFTER_SEND) - status = cmd.get_STATE(prt).hex() - if status is not None: - print("Received: STATE. State status:", cmd.decode_STATE(status), "("+cmd.flipfour(status)+")") - print("") - else: - print("") - -def request_data(prt): - # Request data - cmd.send_TRANS_ENABLE(prt) - time.sleep(WAIT_AFTER_SEND) - data = cmd.get_DATA(prt).hex() - data_dict = [] - if data is not None: - data_dict = cmd.decode_DATA(data) - return data_dict - - -def print_data(data): - def shorten(i): - return str(round(i, 2)) - - print("Data from device (time: "+datetime.now().strftime("%H:%M:%S:%f")+"):") - print("Message Header:", data['Header'], " Message ID:", data['Message_ID']) - print("Photodiode Current 1 ("+str(len(data['I1']))+" values):", \ - shorten(data['I1']), shorten(data['I1'][1]), "...", \ - shorten(data['I1']), shorten(data['I1'][-1]), "mA") - print("Photodiode Current 2 ("+str(len(data['I2']))+" values):", \ - shorten(data['I2']), shorten(data['I2'][1]), "...", \ - shorten(data['I2']), shorten(data['I2'][-1]), "mA") - print("Laser Temperature 1:", shorten(data['Temp_1']), "C") - print("Laser Temperature 2:", shorten(data['Temp_2']), "C") - print("Temperature of external thermistor 1:", shorten(data['Temp_Ext_1']), "C") - print("Temperature of external thermistor 2:", shorten(data['Temp_Ext_2']), "C") - print("Voltages 3V3: "+shorten(data['MON_3V3'])+"V 5V1: "+shorten(data['MON_5V1'])+ \ - "V 5V2: "+shorten(data['MON_5V2'])+"V 7V0: "+shorten(data['MON_7V0'])+"V.") - -def close_connection(prt): - cmd.close_port(prt) diff --git a/gui.py b/gui.py deleted file mode 100644 index 7b3772f..0000000 --- a/gui.py +++ /dev/null @@ -1,236 +0,0 @@ -#from Tools.scripts.highlight import default_html - -#default_html -import FreeSimpleGUI as sg - -#### ---- GUI Constants - -WINDOW_TITLE = 'Модуль управления лазерной схемой оптического смесителя (Отдел радиофотоники МФТИ)' -WINDOW_SIZE = [0, 0] -COMPACT_LAYOUT = False - -SET_BUTTON_TEXT = 'Задать' - -SET_TEMPERATURE_TEXT_1 = 'Установка температуры лазера 1 (C):' -SET_TEMPERATURE_TEXT_2 = 'Установка температуры лазера 2 (C):' -SET_CURRENT_TEXT_1 = 'Управляющий ток лазера 1 (15-60 мА):' -SET_CURRENT_TEXT_2 = 'Управляющий ток лазера 2 (15-60 мА):' -SET_MANUAL_MODE_TEXT = 'Ручной режим ввода' -SET_TEXT_WIDTH = 34 -SET_INPUT_WIDTH = 5 - -SET_MIN_TEMPERATURE_TEXT_1 = 'Минимальная температура лазера 1 (C):' -SET_MAX_TEMPERATURE_TEXT_1 = 'Максимальная температура лазера 1 (C):' -SET_DELTA_TEMPERATURE_TEXT_1 = 'Шаг дискретизации температуры лазера 1 (0.05-1 С):' -SET_MIN_CURRENT_TEXT_1 = 'Мнимальный ток лазера 1 (мА):' -SET_MAX_CURRENT_TEXT_1 = 'Максимальный ток лазера 1 (мА):' -SET_DELTA_CURRENT_TEXT_1 = 'Шаг дискретизации тока лазера 1 (0.002-0.5 мА):' -SET_MIN_TEMPERATURE_TEXT_2 = 'Минимальная температура лазера 2 (C):' -SET_MAX_TEMPERATURE_TEXT_2 = 'Максимальная температура лазера 2 (C):' -SET_DELTA_TEMPERATURE_TEXT_2 = 'Шаг дискретизации температуры лазера 2 (0.05-1 С):' -SET_MIN_CURRENT_TEXT_2 = 'Мнимальный ток лазера 2 (мА):' -SET_MAX_CURRENT_TEXT_2 = 'Максимальный ток лазера 2 (мА):' -SET_DELTA_CURRENT_TEXT_2 = 'Шаг дискретизации тока лазера 2 (0.002-0.5 мА):' -SET_DELTA_T_TEXT = 'Шаг дискретизации времени (20-100 мкс, шаг 10 мкс):' -SET_TAU_T_TEXT = 'Время задержки (3-10мс):' -SET_TEXT_WIDTH_NEW = 40 - -SET_START_BUTTON_TEXT = 'Пуск' -SET_STOP_BUTTON_TEXT = 'Стоп' - - -GRAPH_POINTS_NUMBER = 100 # Number of most recent data points shown on charts -GRAPH_CANVAS_SIZE = (0, 0) -GRAPH_BG_COLOR = '#303030' -GRAPH_SIGN_AXES_COLOR = 'orange' - -GRAPH_T_MIN = 0 # Celsius -GRAPH_T_MAX = 50 # Celsius -GRAPH_I_MIN = 0.0 # mA -GRAPH_I_MAX = 1.0 # mA - - -READ_TEMPERATURE_TEXT = 'Температура лазера' -READ_CURRENT_TEXT = 'Ток фотодиода' - -VOLTAGE_TEXT_WIDTH = 15 -H_SEPARATOR_PAD = (1, 20) -OUTPUT_TEXT_PAD = (5, (20, 5)) -WINDOW_MARGIN = (60, 90) -MIN_WINDOW_SIZE = (880, 660) - - -#### ---- Setting GUI - -def get_screen_size(): - global WINDOW_SIZE, GRAPH_CANVAS_SIZE, COMPACT_LAYOUT, SET_TEXT_WIDTH, SET_TEXT_WIDTH_NEW - global H_SEPARATOR_PAD, OUTPUT_TEXT_PAD - - window = sg.Window('Test') - screen_width, screen_height = window.get_screen_size() - window.close() - - COMPACT_LAYOUT = True - - margin_w, margin_h = WINDOW_MARGIN - min_w, min_h = MIN_WINDOW_SIZE - - window_width = min(screen_width, max(min_w, screen_width - margin_w)) - window_height = min(screen_height, max(min_h, screen_height - margin_h))//2 - WINDOW_SIZE = (window_width, window_height) - - if COMPACT_LAYOUT: - SET_TEXT_WIDTH = 30 - SET_TEXT_WIDTH_NEW = 34 - graph_width = min(int(screen_width / 7.2), int(window_width / 5.6)) - graph_height = max(90, int(screen_height / 16)) - H_SEPARATOR_PAD = (1, 8) - OUTPUT_TEXT_PAD = (5, (8, 3)) - else: - SET_TEXT_WIDTH = 34 - SET_TEXT_WIDTH_NEW = 40 - graph_width = int(screen_width / 4) - graph_height = int(screen_width / (3 * 3.5)) - H_SEPARATOR_PAD = (1, 15) - OUTPUT_TEXT_PAD = (5, (15, 5)) - - graph_width = max(180, graph_width) - GRAPH_CANVAS_SIZE = (graph_width, graph_height) - - return WINDOW_SIZE - -def setup_gui(params): - - sg.theme("DarkBlue12") - - window_size = get_screen_size() - - layout_input_col1 = [[sg.Text(SET_TEMPERATURE_TEXT_1, size=(SET_TEXT_WIDTH, 1)), sg.Push(), - sg.Input(params['Temp_1'], disabled_readonly_background_color="Gray", size=(SET_INPUT_WIDTH,1), key='-InputT1-', disabled = True)], - - [sg.Text(SET_CURRENT_TEXT_1, size=(SET_TEXT_WIDTH, 1)), sg.Push(), - sg.Input(params['Iset_1'], disabled_readonly_background_color="Gray", size=(SET_INPUT_WIDTH,1), key='-InputI1-', disabled = True)], - - [sg.HSeparator(pad=H_SEPARATOR_PAD)], - - [sg.Push(), sg.Text(READ_TEMPERATURE_TEXT+' 1: ', key='-TOUT_1-')], - - [sg.Graph(canvas_size=GRAPH_CANVAS_SIZE, graph_bottom_left=(0, GRAPH_T_MIN), graph_top_right=(GRAPH_POINTS_NUMBER, GRAPH_T_MAX), - background_color=GRAPH_BG_COLOR, enable_events=False, drag_submits=False, key='-GraphT1-')], - - # [sg.HSeparator(pad=(10,15), color=sg.theme_background_color())], - - [sg.Push(), sg.Text(READ_CURRENT_TEXT+' 1: ', pad=OUTPUT_TEXT_PAD, key='-IOUT_1-')], - - [sg.Graph(canvas_size=GRAPH_CANVAS_SIZE, graph_bottom_left=(0, GRAPH_I_MIN), graph_top_right=(GRAPH_POINTS_NUMBER, GRAPH_I_MAX), - background_color=GRAPH_BG_COLOR, enable_events=False, drag_submits=False, key='-GraphI1-')]] - - - layout_input_col2 = [[sg.Text(SET_TEMPERATURE_TEXT_2, size=(SET_TEXT_WIDTH, 1)), sg.Push(), - sg.Input(params['Temp_2'], disabled_readonly_background_color="Gray", size=(SET_INPUT_WIDTH,1), key='-InputT2-', disabled = True)], - - [sg.Text(SET_CURRENT_TEXT_2, size=(SET_TEXT_WIDTH, 1)), sg.Push(), - sg.Input(params['Iset_2'], disabled_readonly_background_color="Gray", size=(SET_INPUT_WIDTH,1), key='-InputI2-', disabled = True)], - - [sg.HSeparator(pad=H_SEPARATOR_PAD)], - - [sg.Push(), sg.Text(READ_TEMPERATURE_TEXT+' 2: ', key='-TOUT_2-')], - - [sg.Graph(canvas_size=GRAPH_CANVAS_SIZE, graph_bottom_left=(0, GRAPH_T_MIN), graph_top_right=(GRAPH_POINTS_NUMBER, GRAPH_T_MAX), - background_color=GRAPH_BG_COLOR, enable_events=False, drag_submits=False, key='-GraphT2-')], - - # [sg.HSeparator(pad=(10,15), color=sg.theme_background_color())], - - [sg.Push(), sg.Text(READ_CURRENT_TEXT+' 2: ', pad=OUTPUT_TEXT_PAD, key='-IOUT_2-')], - - [sg.Graph(canvas_size=GRAPH_CANVAS_SIZE, graph_bottom_left=(0, GRAPH_I_MIN), graph_top_right=(GRAPH_POINTS_NUMBER, GRAPH_I_MAX), - background_color=GRAPH_BG_COLOR, enable_events=False, drag_submits=False, key='-GraphI2-')]] - - layout_input_col3 = [ - [sg.Text(SET_MANUAL_MODE_TEXT, size=(SET_TEXT_WIDTH_NEW, 1)), sg.Checkbox('', default=False, key='-EnableManualSettings-')], - - [sg.Text(SET_MIN_TEMPERATURE_TEXT_1, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Min_Temp_1'], size=(SET_INPUT_WIDTH,1), key='-InputMinT1-', disabled=True, disabled_readonly_background_color="Gray"), sg.Checkbox('', default=False, key='-EnableT1-')], - - [sg.Text(SET_MAX_TEMPERATURE_TEXT_1, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Max_Temp_1'], size=(SET_INPUT_WIDTH,1), key='-InputMaxT1-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.Text(SET_MIN_CURRENT_TEXT_1, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Min_Current_1'], size=(SET_INPUT_WIDTH,1), key='-InputMinC1-', disabled=True, disabled_readonly_background_color="Gray"), sg.Checkbox('', default=False, key='-EnableC1-')], - - [sg.Text(SET_MAX_CURRENT_TEXT_1, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Max_Current_1'], size=(SET_INPUT_WIDTH,1), key='-InputMaxC1-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.Text(SET_DELTA_TEMPERATURE_TEXT_1, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Delta_Temp_1'], size=(SET_INPUT_WIDTH,1), key='-InputDeltaT1-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.Text(SET_DELTA_CURRENT_TEXT_1, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Delta_Current_1'], size=(SET_INPUT_WIDTH,1), key='-InputDeltaC1-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.HSeparator(pad=H_SEPARATOR_PAD)], - - [sg.Text(SET_MIN_TEMPERATURE_TEXT_2, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Min_Temp_2'], size=(SET_INPUT_WIDTH,1), key='-InputMinT2-', disabled=True, disabled_readonly_background_color="Gray"), sg.Checkbox('', default=False, key='-EnableT2-')], - - [sg.Text(SET_MAX_TEMPERATURE_TEXT_2, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Max_Temp_2'], size=(SET_INPUT_WIDTH,1), key='-InputMaxT2-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.Text(SET_MIN_CURRENT_TEXT_2, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Min_Current_2'], size=(SET_INPUT_WIDTH,1), key='-InputMinC2-', disabled=True, disabled_readonly_background_color="Gray"), sg.Checkbox('', default=False, key='-EnableC2-')], - - [sg.Text(SET_MAX_CURRENT_TEXT_2, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Max_Current_2'], size=(SET_INPUT_WIDTH,1), key='-InputMaxC2-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.Text(SET_DELTA_TEMPERATURE_TEXT_2, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Delta_Temp_2'], size=(SET_INPUT_WIDTH,1), key='-InputDeltaT2-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.Text(SET_DELTA_CURRENT_TEXT_2, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Delta_Current_2'], size=(SET_INPUT_WIDTH,1), key='-InputDeltaC2-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.HSeparator(pad=H_SEPARATOR_PAD)], - - [sg.Text(SET_DELTA_T_TEXT, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Delta_Time'], size=(SET_INPUT_WIDTH,1), key='-InputDeltaTime-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.Text(SET_TAU_T_TEXT, size=(SET_TEXT_WIDTH_NEW,1)), - sg.Input(params['Tau'], size=(SET_INPUT_WIDTH,1), key='-InputTau-', disabled=True, disabled_readonly_background_color="Gray")], - - [sg.HSeparator(pad=H_SEPARATOR_PAD)], - - [sg.Button(SET_START_BUTTON_TEXT, key='-StartCycle-', disabled_button_color=("Gray22", "Blue"), disabled=True), sg.Button(SET_STOP_BUTTON_TEXT, disabled_button_color=("Gray22", "Blue"), key='-StopCycle-', disabled=True)]] - - - layout = [[sg.Column(layout_input_col1, pad=(0,0)), sg.VSeparator(pad=(4,0)), sg.Column(layout_input_col2, pad=(0,0)), sg.VSeparator(pad=(4,0)), sg.Column(layout_input_col3, pad=(0,0))], - - [sg.HSeparator(pad=(25,10))], - - [sg.Text('', size=((3 if COMPACT_LAYOUT else 7),1)), - sg.Text('T терм 1:', size=(VOLTAGE_TEXT_WIDTH,1), key='-TTerm1-'), sg.Text('T терм 2:', size=(VOLTAGE_TEXT_WIDTH,1), key='-TTerm2-'), - sg.Text('I1:', size=(VOLTAGE_TEXT_WIDTH,1), key='-I1_PANEL-'), sg.Text('I2:', size=(VOLTAGE_TEXT_WIDTH,1), key='-I2_PANEL-'), - sg.Text('3V3:', size=(VOLTAGE_TEXT_WIDTH,1), key='-3V3-'), sg.Text('5V1:', size=(VOLTAGE_TEXT_WIDTH,1), key='-5V1-'), - sg.Text('5V2:', size=(VOLTAGE_TEXT_WIDTH,1), key='-5V2-'), sg.Text('7V0:', size=(VOLTAGE_TEXT_WIDTH,1), key='-7V0-'), - sg.Push(), sg.Text('', key='-DateTime-', pad=(1,10)), - sg.Text('', size=(10,1))], - - [sg.Exit('Выход', pad=(1,5), size=(10,1), key='-EXIT-')]] - - window = sg.Window(WINDOW_TITLE, layout, finalize=True, element_justification='c', size=window_size, resizable=True) - window.bind('', '-EXIT-') - return window - - -def sign_axes(window): - signs_dict = {} - signs_dict['-GraphT1-'] = \ - (window['-GraphT1-'].draw_text(text=str(GRAPH_T_MIN)+' C', location=(3, GRAPH_T_MIN+(GRAPH_T_MAX-GRAPH_T_MIN)*0.05), color=GRAPH_SIGN_AXES_COLOR), - window['-GraphT1-'].draw_text(text=str(GRAPH_T_MAX)+' C', location=(3, GRAPH_T_MAX-(GRAPH_T_MAX-GRAPH_T_MIN)*0.05), color=GRAPH_SIGN_AXES_COLOR)) - signs_dict['-GraphI1-'] = \ - (window['-GraphI1-'].draw_text(text=str(GRAPH_I_MIN)+' мА', location=(4, GRAPH_I_MIN+(GRAPH_I_MAX-GRAPH_I_MIN)*0.05), color=GRAPH_SIGN_AXES_COLOR), - window['-GraphI1-'].draw_text(text=str(GRAPH_I_MAX)+' мА', location=(4, GRAPH_I_MAX-(GRAPH_I_MAX-GRAPH_I_MIN)*0.05), color=GRAPH_SIGN_AXES_COLOR)) - signs_dict['-GraphT2-'] = \ - (window['-GraphT2-'].draw_text(text=str(GRAPH_T_MIN)+' C', location=(3, GRAPH_T_MIN+(GRAPH_T_MAX-GRAPH_T_MIN)*0.05), color=GRAPH_SIGN_AXES_COLOR), - window['-GraphT2-'].draw_text(text=str(GRAPH_T_MAX)+' C', location=(3, GRAPH_T_MAX-(GRAPH_T_MAX-GRAPH_T_MIN)*0.05), color=GRAPH_SIGN_AXES_COLOR)) - signs_dict['-GraphI2-'] = \ - (window['-GraphI2-'].draw_text(text=str(GRAPH_I_MIN)+' мА', location=(4, GRAPH_I_MIN+(GRAPH_I_MAX-GRAPH_I_MIN)*0.05), color=GRAPH_SIGN_AXES_COLOR), - window['-GraphI2-'].draw_text(text=str(GRAPH_I_MAX)+' мА', location=(4, GRAPH_I_MAX-(GRAPH_I_MAX-GRAPH_I_MIN)*0.05), color=GRAPH_SIGN_AXES_COLOR)) - return signs_dict diff --git a/init_params.json b/init_params.json deleted file mode 100644 index 5018b35..0000000 --- a/init_params.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - - {INITIAL_TEMPERATURE_1 = 28 # Set initial temperature for Laser 1 in Celsius: from -1 to 45 C ?? -INITIAL_TEMPERATURE_2 = 28.9 # Set initial temperature for Laser 2 in Celsius: from -1 to 45 C ?? -INITIAL_CURRENT_1 = 33 # 64.0879 max # Set initial current for Laser 1, in mA -INITIAL_CURRENT_2 = 35 # 64.0879 max # Set initial current for Laser 2, in mA - -GUI_TIMEOUT_INTERVAL = 5#505 - dev.WAIT_AFTER_SEND*1000 # GUI refresh time in milliseconds - -SAVE_POINTS_NUMBER = 1000 # Number of most recent data points kept in memory -} \ No newline at end of file diff --git a/laser_control/__init__.py b/laser_control/__init__.py index 32a9600..888eb04 100644 --- a/laser_control/__init__.py +++ b/laser_control/__init__.py @@ -1,35 +1,36 @@ -""" -Laser Control Module - -A standalone module for controlling dual laser systems with temperature and current regulation. -Provides a clean API for integration into any Python application. -""" +"""Public package exports for the refactored laser-control application.""" from .controller import LaserController -from .models import ( - DeviceStatus, - Measurements, - ManualModeParams, - VariationParams, - VariationType -) +from .models import DeviceState, DeviceStatus, Measurements from .exceptions import ( LaserControlError, ValidationError, CommunicationError, - DeviceError + DeviceError, + CurrentOutOfRangeError, + DeviceNotRespondingError, + DeviceStateError, + InvalidParameterError, + PortNotFoundError, + ProtocolError, + TemperatureOutOfRangeError, ) -__version__ = "1.0.0" +__version__ = "2.0.0" __all__ = [ "LaserController", + "DeviceState", "DeviceStatus", "Measurements", - "ManualModeParams", - "VariationParams", - "VariationType", "LaserControlError", "ValidationError", "CommunicationError", - "DeviceError" -] \ No newline at end of file + "CurrentOutOfRangeError", + "DeviceError", + "DeviceNotRespondingError", + "DeviceStateError", + "InvalidParameterError", + "PortNotFoundError", + "ProtocolError", + "TemperatureOutOfRangeError", +] diff --git a/laser_control/constants.py b/laser_control/constants.py index ac63179..650a77a 100644 --- a/laser_control/constants.py +++ b/laser_control/constants.py @@ -1,122 +1,229 @@ -""" -Constants for laser control module. +"""Shared constants for protocol, validation, transport, and GUI defaults.""" -Physical constraints, protocol parameters, and operational limits -extracted from original device_commands.py and device_conversion.py. -""" - -# ---- Protocol constants +# ---- Transport / timing BAUDRATE = 115200 SERIAL_TIMEOUT_SEC = 1.0 +WAIT_AFTER_SEND_SEC = 0.15 -GET_DATA_TOTAL_LENGTH = 30 # bytes in device DATA response -SEND_PARAMS_TOTAL_LENGTH = 30 # bytes in DECODE_ENABLE command -TASK_ENABLE_COMMAND_LENGTH = 32 # bytes in TASK_ENABLE command +GUI_POLL_INTERVAL_MS = 150 +GUI_STATUS_INTERVAL_MS = 1000 -WAIT_AFTER_SEND_SEC = 0.15 # delay after sending a command -GUI_POLL_INTERVAL_MS = 5 # GUI event loop timeout +# ---- Packet sizes -# ---- Command codes (as sent to device, already flipped to LE) +GET_DATA_TOTAL_LENGTH = 30 +SEND_PARAMS_TOTAL_LENGTH = 30 +SHORT_CONTROL_TOTAL_LENGTH = 10 +WAVE_DATA_TOTAL_LENGTH = 30 +PROFILE_SAVE_CONTROL_TOTAL_LENGTH = 30 +PROFILE_SAVE_DATA_TOTAL_LENGTH = 30 +SHORT_COMMAND_LENGTH = 2 +STATUS_RESPONSE_LENGTH = 2 -CMD_DECODE_ENABLE = 0x1111 # Set control parameters -CMD_DEFAULT_ENABLE = 0x2222 # Reset device -CMD_TRANSS_ENABLE = 0x3333 # Request all saved data (not implemented) -CMD_TRANS_ENABLE = 0x4444 # Request last data -CMD_REMOVE_FILE = 0x5555 # Delete saved data -CMD_STATE = 0x6666 # Request state -CMD_TASK_ENABLE = 0x7777 # Start a task +# ---- Supported firmware commands -# ---- Error codes from device STATE response (after flipfour) +CMD_DECODE_ENABLE = 0x1111 +CMD_DEFAULT_ENABLE = 0x2222 +CMD_TRANS_ENABLE = 0x4444 +CMD_STATE = 0x6666 +CMD_PROFILE_SAVE_CONTROL = 0x7777 +CMD_AD9102_CONTROL = 0x8888 +CMD_AD9833_CONTROL = 0x9999 +CMD_DS1809_CONTROL = 0xAAAA +CMD_STM32_DAC_CONTROL = 0xBBBB +CMD_AD9102_WAVE_CONTROL = 0xCCCC +CMD_AD9102_WAVE_DATA = 0xDDDD +CMD_PROFILE_SAVE_DATA = 0xEEEE -STATE_OK = '0000' -STATE_SD_ERR = '0001' # SD Card read/write error -STATE_UART_ERR = '0002' # Command (UART) error -STATE_UART_DECODE_ERR = '0004' # Wrong parameter value -STATE_TEC1_ERR = '0008' # Laser 1 TEC driver overheat -STATE_TEC2_ERR = '0010' # Laser 2 TEC driver overheat -STATE_DEFAULT_ERR = '0020' # System reset error -STATE_REMOVE_ERR = '0040' # File deletion error +# ---- Setup-word bit layout from firmware app_decode_work_packet() -STATE_DESCRIPTIONS = { - STATE_OK: "All ok.", - STATE_SD_ERR: "SD Card reading/writing error (SD_ERR).", - STATE_UART_ERR: "Command error (UART_ERR).", - STATE_UART_DECODE_ERR:"Wrong parameter value error (UART_DECODE_ERR).", - STATE_TEC1_ERR: "Laser 1: TEC driver overheat (TEC1_ERR).", - STATE_TEC2_ERR: "Laser 2: TEC driver overheat (TEC2_ERR).", - STATE_DEFAULT_ERR: "Resetting system error (DEFAULT_ERR).", - STATE_REMOVE_ERR: "File deletion error (REMOVE_ERR).", +SETUP_WORK_ENABLED = 1 << 0 +SETUP_SUPPLY_5V1_ENABLED = 1 << 1 +SETUP_SUPPLY_5V2_ENABLED = 1 << 2 +SETUP_LASER1_ENABLED = 1 << 3 +SETUP_LASER2_ENABLED = 1 << 4 +SETUP_REFERENCE1_ENABLED = 1 << 5 +SETUP_REFERENCE2_ENABLED = 1 << 6 +SETUP_TEC1_ENABLED = 1 << 7 +SETUP_TEC2_ENABLED = 1 << 8 +SETUP_TEMP_SENSOR1_ENABLED = 1 << 9 +SETUP_TEMP_SENSOR2_ENABLED = 1 << 10 +SETUP_PID1_FROM_HOST = 1 << 12 +SETUP_PID2_FROM_HOST = 1 << 13 + +DEFAULT_SETUP_WORD = ( + SETUP_WORK_ENABLED + | SETUP_SUPPLY_5V1_ENABLED + | SETUP_SUPPLY_5V2_ENABLED + | SETUP_LASER1_ENABLED + | SETUP_LASER2_ENABLED + | SETUP_REFERENCE1_ENABLED + | SETUP_REFERENCE2_ENABLED + | SETUP_TEC1_ENABLED + | SETUP_TEC2_ENABLED + | SETUP_TEMP_SENSOR1_ENABLED + | SETUP_TEMP_SENSOR2_ENABLED + | SETUP_PID1_FROM_HOST + | SETUP_PID2_FROM_HOST +) + +# ---- Status-byte flags from firmware app_types.h + +STATUS_FLAG_SD_ERROR = 0x01 +STATUS_FLAG_UART_ERROR = 0x02 +STATUS_FLAG_UART_DECODE_ERROR = 0x04 +STATUS_FLAG_TEC1_ERROR = 0x08 +STATUS_FLAG_TEC2_ERROR = 0x10 +STATUS_FLAG_DEFAULT_ERROR = 0x20 +STATUS_FLAG_AD9102_ERROR = 0x80 + +STATUS_DESCRIPTIONS = { + STATUS_FLAG_SD_ERROR: "SD card read/write error.", + STATUS_FLAG_UART_ERROR: "UART framing or header error.", + STATUS_FLAG_UART_DECODE_ERROR: "Command payload validation error.", + STATUS_FLAG_TEC1_ERROR: "Laser 1 TEC driver overheat.", + STATUS_FLAG_TEC2_ERROR: "Laser 2 TEC driver overheat.", + STATUS_FLAG_DEFAULT_ERROR: "Device reset/default handling error.", + STATUS_FLAG_AD9102_ERROR: "AD9102 configuration or waveform error.", } -# ---- Physical / hardware constants (from device_conversion.py) +# ---- Peripheral control flags from firmware app_types.h -VREF = 2.5 # Reference voltage, Volts +AD9102_FLAG_ENABLE = 0x0001 +AD9102_FLAG_TRIANGLE = 0x0002 +AD9102_FLAG_SRAM = 0x0004 +AD9102_FLAG_SRAM_FORMAT_ALT = 0x0008 -# Bridge resistors for temperature measurement -R1 = 10000 # Ohm -R2 = 2200 # Ohm -R3 = 27000 # Ohm -R4 = 30000 # Ohm -R5 = 27000 # Ohm -R6 = 56000 # Ohm +AD9833_FLAG_ENABLE = 0x0001 +AD9833_FLAG_TRIANGLE = 0x0002 -RREF = 10 # Current-setting resistor, Ohm - # (@1550 nm – 28.7 Ohm; @840 nm – 10 Ohm) +DS1809_FLAG_INCREMENT = 0x0001 +DS1809_FLAG_DECREMENT = 0x0002 -# External thermistor divider resistors -R7 = 22000 # Ohm -R8 = 22000 # Ohm -R9 = 5100 # Ohm -R10 = 180000 # Ohm +STM32_DAC_FLAG_ENABLE = 0x0001 -# Thermistor Steinhart–Hart B-coefficient (internal / external) -BETA_INTERNAL = 3900 # K -BETA_EXTERNAL = 3455 # K -T0_K = 298 # Kelvin (25 °C reference) -R0 = 10000 # Ohm (thermistor nominal at 25 °C) +AD9102_WAVE_OPCODE_BEGIN = 0x0001 +AD9102_WAVE_OPCODE_COMMIT = 0x0002 +AD9102_WAVE_OPCODE_CANCEL = 0x0003 -# ADC resolution -ADC_BITS_16 = 65535 # 2^16 - 1 -ADC_BITS_12 = 4095 # 2^12 - 1 +PROFILE_SAVE_OPCODE_BEGIN = 0x0001 +PROFILE_SAVE_OPCODE_COMMIT = 0x0002 +PROFILE_SAVE_OPCODE_CANCEL = 0x0003 -# Voltage conversion coefficients -U3V3_COEFF = 1.221e-3 # counts → Volts for 3.3V rail -U5V_COEFF = 1.8315e-3 # counts → Volts for 5V rails -U7V_COEFF = 6.72e-3 # counts → Volts for 7V rail +PROFILE_SAVE_SECTION_PROFILE_TEXT = 0x0001 +PROFILE_SAVE_SECTION_WAVEFORM_TEXT = 0x0002 -# ---- Operational limits (validated in validators.py) +# ---- Physical constants from the existing conversion formulas -TEMP_MIN_C = 15.0 # Minimum allowed laser temperature, °C -TEMP_MAX_C = 40.0 # Maximum allowed laser temperature, °C +VREF = 2.5 -CURRENT_MIN_MA = 15.0 # Minimum allowed laser current, mA -CURRENT_MAX_MA = 60.0 # Maximum allowed laser current, mA +R1 = 10000 +R2 = 2200 +R3 = 27000 +R4 = 30000 +R5 = 27000 +R6 = 56000 -# Variation step limits -CURRENT_STEP_MIN_MA = 0.002 # Minimum current variation step, mA -CURRENT_STEP_MAX_MA = 0.5 # Maximum current variation step, mA +RREF = 30 -TEMP_STEP_MIN_C = 0.05 # Minimum temperature variation step, °C -TEMP_STEP_MAX_C = 1.0 # Maximum temperature variation step, °C +R7 = 22000 +R8 = 22000 +R9 = 5100 +R10 = 180000 -# Time parameter limits -TIME_STEP_MIN_US = 20 # Minimum time step, microseconds -TIME_STEP_MAX_US = 100 # Maximum time step, microseconds +BETA_INTERNAL = 3900 +BETA_EXTERNAL = 3455 +T0_K = 298 +R0 = 10000 -DELAY_TIME_MIN_MS = 3 # Minimum delay between pulses, milliseconds -DELAY_TIME_MAX_MS = 10 # Maximum delay between pulses, milliseconds +ADC_BITS_16 = 65535 +ADC_BITS_12 = 4095 -# ---- Acceptable voltage tolerances for power rail health check +U3V3_COEFF = 1.221e-3 +U5V_COEFF = 1.8315e-3 +U7V_COEFF = 6.72e-3 + +# ---- Validation limits + +TEMP_MIN_C = 15.0 +TEMP_MAX_C = 40.0 + +CURRENT_MIN_MA = 15.0 +CURRENT_MAX_MA = 60.0 + +AD9102_SAW_STEP_MIN = 1 +AD9102_SAW_STEP_MAX = 63 +AD9102_PAT_BASE_MIN = 0 +AD9102_PAT_BASE_MAX = 15 +AD9102_PAT_PERIOD_MIN = 0 +AD9102_PAT_PERIOD_MAX = 65535 +AD9102_SRAM_SAMPLE_MIN = 2 +AD9102_SRAM_SAMPLE_MAX = 4096 +AD9102_SRAM_HOLD_MIN = 0 +AD9102_SRAM_HOLD_MAX = 15 +AD9102_SRAM_AMPLITUDE_MIN = 0 +AD9102_SRAM_AMPLITUDE_MAX = 8191 +AD9102_WAVE_SAMPLE_MIN = -8192 +AD9102_WAVE_SAMPLE_MAX = 8191 +AD9102_WAVE_MAX_CHUNK_SAMPLES = 12 +AD9102_CLOCK_HZ = 150_000_000 + +AD9833_FREQ_WORD_MIN = 0 +AD9833_FREQ_WORD_MAX = 0x0FFFFFFF +AD9833_MCLK_HZ = 20_000_000 +AD9833_OUTPUT_FREQ_MIN_HZ = 0 +AD9833_OUTPUT_FREQ_MAX_HZ = AD9833_MCLK_HZ // 2 + +DS1809_COUNT_MIN = 1 +DS1809_COUNT_MAX = 64 +DS1809_PULSE_MS_MIN = 1 +DS1809_PULSE_MS_MAX = 500 +DS1809_PROFILE_POSITION_MIN = 0 +DS1809_PROFILE_POSITION_MAX = 63 + +STM32_DAC_CODE_MIN = 0 +STM32_DAC_CODE_MAX = 4095 + +# ---- Rail tolerances VOLT_3V3_MIN = 3.1 VOLT_3V3_MAX = 3.5 -VOLT_5V_MIN = 4.8 -VOLT_5V_MAX = 5.3 -VOLT_7V_MIN = 6.5 -VOLT_7V_MAX = 7.5 +VOLT_5V_MIN = 4.8 +VOLT_5V_MAX = 5.3 +VOLT_7V_MIN = 6.5 +VOLT_7V_MAX = 7.5 -# ---- Data buffer limits +# ---- UI / runtime defaults -MAX_DATA_POINTS = 1000 # Max stored measurement points -PLOT_POINTS = 100 # Points shown in real-time plots \ No newline at end of file +DEFAULT_TEMP1_C = 28.0 +DEFAULT_TEMP2_C = 29.2 +DEFAULT_CURRENT1_MA = 33.0 +DEFAULT_CURRENT2_MA = 60.0 + +DEFAULT_AD9102_SAW_STEP = 1 +DEFAULT_AD9102_PAT_BASE = 2 +DEFAULT_AD9102_PAT_PERIOD = 0xFFFF +DEFAULT_AD9102_SAMPLE_COUNT = 16 +DEFAULT_AD9102_HOLD_CYCLES = 1 +DEFAULT_AD9102_AMPLITUDE = 8191 +DEFAULT_AD9102_SAW_FREQUENCY_HZ = 4577 +DEFAULT_AD9102_SRAM_FREQUENCY_HZ = 9_375_000 +DEFAULT_AD9833_FREQ_WORD = 0 +DEFAULT_AD9833_FREQUENCY_HZ = 1_000_000 +DEFAULT_DS1809_COUNT = 1 +DEFAULT_DS1809_PULSE_MS = 2 +DEFAULT_DS1809_PROFILE_POSITION = 39 +DEFAULT_STM32_DAC_VOLT = 0.52 +DEFAULT_STM32_DAC_VREF = 2.5 +DEFAULT_STM32_DAC_CODE = round( + DEFAULT_STM32_DAC_VOLT / DEFAULT_STM32_DAC_VREF * STM32_DAC_CODE_MAX +) + +DEFAULT_PI_P = 2560 +DEFAULT_PI_I = 128 + +PROFILE_NAME_MAX_LENGTH = 16 +PROFILE_NAME_ALLOWED_PATTERN = r"[A-Za-z0-9 _-]{1,16}" +PROFILE_SAVE_DATA_CHUNK_BYTES = 22 + +PLOT_POINTS = 100 diff --git a/laser_control/controller.py b/laser_control/controller.py index 1fa1f07..6799eac 100644 --- a/laser_control/controller.py +++ b/laser_control/controller.py @@ -1,119 +1,181 @@ -""" -Main laser controller for the laser control module. +"""High-level controller orchestrating protocol encoding and serial transport.""" -Provides a high-level API for controlling dual laser systems. -All input parameters are validated before being sent to the device. -Can be embedded in any Python application without GUI dependencies. -""" +from __future__ import annotations -import time import logging -from typing import Optional, Callable +import math +import time +from typing import Callable, Sequence -from .protocol import Protocol, TaskType as ProtoTaskType -from .validators import ParameterValidator -from .models import ( - ManualModeParams, - VariationParams, - VariationType, - Measurements, - DeviceStatus, - DeviceState, +from .constants import ( + AD9102_CLOCK_HZ, + AD9102_PAT_BASE_MAX, + AD9102_PAT_BASE_MIN, + AD9102_PAT_PERIOD_MAX, + AD9102_PAT_PERIOD_MIN, + AD9102_SAW_STEP_MAX, + AD9102_SAW_STEP_MIN, + AD9102_SRAM_AMPLITUDE_MAX, + AD9102_SRAM_AMPLITUDE_MIN, + AD9102_SRAM_HOLD_MAX, + AD9102_SRAM_HOLD_MIN, + AD9102_SRAM_SAMPLE_MAX, + AD9102_SRAM_SAMPLE_MIN, + AD9102_WAVE_MAX_CHUNK_SAMPLES, + AD9102_WAVE_SAMPLE_MAX, + AD9102_WAVE_SAMPLE_MIN, + AD9833_FREQ_WORD_MAX, + AD9833_FREQ_WORD_MIN, + AD9833_MCLK_HZ, + AD9833_OUTPUT_FREQ_MAX_HZ, + AD9833_OUTPUT_FREQ_MIN_HZ, + DEFAULT_CURRENT1_MA, + DEFAULT_AD9102_HOLD_CYCLES, + DEFAULT_AD9102_PAT_BASE, + DEFAULT_AD9102_PAT_PERIOD, + DEFAULT_CURRENT2_MA, + DEFAULT_PI_I, + DEFAULT_PI_P, + DEFAULT_TEMP1_C, + DEFAULT_TEMP2_C, + DS1809_COUNT_MAX, + DS1809_COUNT_MIN, + DS1809_PULSE_MS_MAX, + DS1809_PULSE_MS_MIN, + GET_DATA_TOTAL_LENGTH, + PROFILE_SAVE_DATA_CHUNK_BYTES, + PROFILE_SAVE_SECTION_PROFILE_TEXT, + PROFILE_SAVE_SECTION_WAVEFORM_TEXT, + STM32_DAC_CODE_MAX, + STM32_DAC_CODE_MIN, + STATUS_RESPONSE_LENGTH, + WAIT_AFTER_SEND_SEC, ) from .exceptions import ( - ValidationError, CommunicationError, DeviceNotRespondingError, DeviceStateError, + InvalidParameterError, ) -from .constants import WAIT_AFTER_SEND_SEC +from .models import DeviceState, DeviceStatus, Measurements, ProfileSaveRequest +from .protocol import Protocol +from .transport import SerialTransport +from .validators import ParameterValidator logger = logging.getLogger(__name__) -# Default PI regulator coefficients (match firmware defaults) -DEFAULT_PI_P = 2560 # 10 * 256 -DEFAULT_PI_I = 128 # 0.5 * 256 +_AD9102_SAW_RAMP_STEPS = 1 << 14 + + +def ad9102_saw_frequency_limits_hz(*, triangle: bool) -> tuple[int, int]: + """Return the reachable frequency range for the built-in saw generator.""" + factor = 2 if triangle else 1 + minimum = math.ceil(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * AD9102_SAW_STEP_MAX)) + maximum = math.floor(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * AD9102_SAW_STEP_MIN)) + return minimum, maximum + + +def ad9102_saw_frequency_from_step_hz(*, triangle: bool, saw_step: int) -> float: + """Calculate the actual built-in saw/triangle frequency for a given SAW_STEP.""" + factor = 2 if triangle else 1 + saw_step = max(AD9102_SAW_STEP_MIN, min(AD9102_SAW_STEP_MAX, int(saw_step))) + return AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * saw_step) + + +def ad9102_saw_step_from_frequency_hz(*, triangle: bool, frequency_hz: int) -> tuple[int, float]: + """Map a desired built-in saw frequency to the closest supported SAW_STEP.""" + min_hz, max_hz = ad9102_saw_frequency_limits_hz(triangle=triangle) + if frequency_hz < min_hz or frequency_hz > max_hz: + raise InvalidParameterError( + f"frequency_hz must be in range [{min_hz}, {max_hz}] for this AD9102 mode" + ) + factor = 2 if triangle else 1 + saw_step = round(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * frequency_hz)) + saw_step = max(AD9102_SAW_STEP_MIN, min(AD9102_SAW_STEP_MAX, saw_step)) + return saw_step, ad9102_saw_frequency_from_step_hz(triangle=triangle, saw_step=saw_step) + + +def ad9102_sram_frequency_limits_hz(*, hold_cycles: int = DEFAULT_AD9102_HOLD_CYCLES) -> tuple[int, int]: + """Return the reachable frequency range for SRAM playback for a fixed hold setting.""" + hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES + minimum = math.ceil(AD9102_CLOCK_HZ / (AD9102_SRAM_SAMPLE_MAX * hold)) + maximum = math.floor(AD9102_CLOCK_HZ / (AD9102_SRAM_SAMPLE_MIN * hold)) + return minimum, maximum + + +def ad9102_sram_frequency_from_playback_hz(*, sample_count: int, hold_cycles: int) -> float: + """Calculate the actual SRAM playback frequency.""" + sample_count = max(AD9102_SRAM_SAMPLE_MIN, min(AD9102_SRAM_SAMPLE_MAX, int(sample_count))) + hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES + hold = max(DEFAULT_AD9102_HOLD_CYCLES, min(AD9102_SRAM_HOLD_MAX, int(hold))) + return AD9102_CLOCK_HZ / (sample_count * hold) + + +def ad9102_sram_sample_count_from_frequency_hz( + *, + frequency_hz: int, + hold_cycles: int = DEFAULT_AD9102_HOLD_CYCLES, +) -> tuple[int, float]: + """Map a desired SRAM playback frequency to the closest supported sample count.""" + min_hz, max_hz = ad9102_sram_frequency_limits_hz(hold_cycles=hold_cycles) + if frequency_hz < min_hz or frequency_hz > max_hz: + raise InvalidParameterError( + f"frequency_hz must be in range [{min_hz}, {max_hz}] for this AD9102 mode" + ) + hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES + sample_count = round(AD9102_CLOCK_HZ / (frequency_hz * hold)) + sample_count = max(AD9102_SRAM_SAMPLE_MIN, min(AD9102_SRAM_SAMPLE_MAX, sample_count)) + return sample_count, ad9102_sram_frequency_from_playback_hz( + sample_count=sample_count, + hold_cycles=hold, + ) class LaserController: - """ - High-level controller for the dual laser board. - - Usage example:: - - ctrl = LaserController(port='/dev/ttyUSB0') - ctrl.connect() - ctrl.set_manual_mode(temp1=25.0, temp2=30.0, - current1=40.0, current2=35.0) - data = ctrl.get_measurements() - print(data.voltage_3v3) - ctrl.disconnect() - - All public methods raise :class:`ValidationError` for bad parameters - and :class:`CommunicationError` for transport-level problems. - """ + """Public API for manual control, polling, and status queries.""" def __init__( self, - port: Optional[str] = None, + port: str | None = None, pi_coeff1_p: int = DEFAULT_PI_P, pi_coeff1_i: int = DEFAULT_PI_I, pi_coeff2_p: int = DEFAULT_PI_P, pi_coeff2_i: int = DEFAULT_PI_I, - on_data: Optional[Callable[[Measurements], None]] = None, - ): - """ - Args: - port: Serial port (e.g. '/dev/ttyUSB0'). None = auto-detect. - pi_coeff1_p: Proportional coefficient for laser 1 PI regulator. - pi_coeff1_i: Integral coefficient for laser 1 PI regulator. - pi_coeff2_p: Proportional coefficient for laser 2 PI regulator. - pi_coeff2_i: Integral coefficient for laser 2 PI regulator. - on_data: Optional callback called whenever new measurements - are received. Signature: ``callback(Measurements)``. - """ - self._protocol = Protocol(port) + on_data: Callable[[Measurements], None] | None = None, + ) -> None: + self._transport = SerialTransport(port=port) self._pi1_p = pi_coeff1_p self._pi1_i = pi_coeff1_i self._pi2_p = pi_coeff2_p self._pi2_i = pi_coeff2_i self._on_data = on_data self._message_id = 0 - self._last_measurements: Optional[Measurements] = None - # Last manual-mode params, used to restore state after stop_task() - self._last_temp1: float = 25.0 - self._last_temp2: float = 25.0 - self._last_current1: float = 30.0 - self._last_current2: float = 30.0 - - # ---- Connection ------------------------------------------------------- - - def connect(self) -> bool: - """ - Open connection to the device. - - Returns: - True if connection succeeded. - - Raises: - CommunicationError: If the port cannot be opened. - """ - self._protocol.connect() - logger.info("Connected to laser controller on port %s", - self._protocol._port_name or "auto") - return True - - def disconnect(self) -> None: - """Close the serial port gracefully.""" - self._protocol.disconnect() - logger.info("Disconnected from laser controller") + self._last_measurements: Measurements | None = None + self._last_temp1 = DEFAULT_TEMP1_C + self._last_temp2 = DEFAULT_TEMP2_C + self._last_current1 = DEFAULT_CURRENT1_MA + self._last_current2 = DEFAULT_CURRENT2_MA @property def is_connected(self) -> bool: - """True if the serial port is open.""" - return self._protocol.is_connected + """Return True when the serial port is connected.""" + return self._transport.is_connected - # ---- Public API ------------------------------------------------------- + @property + def port_name(self) -> str | None: + """Return the active serial port name when available.""" + return self._transport.port_name + + def connect(self) -> bool: + """Open the configured serial connection.""" + self._transport.connect() + logger.info("Connected to laser controller on port %s", self.port_name) + return True + + def disconnect(self) -> None: + """Close the serial connection.""" + self._transport.disconnect() + logger.info("Disconnected from laser controller") def set_manual_mode( self, @@ -122,262 +184,413 @@ class LaserController: current1: float, current2: float, ) -> None: - """ - Set manual control parameters for both lasers. - - Args: - temp1: Setpoint temperature for laser 1, °C. - Valid range: [15.0 … 40.0] °C. - temp2: Setpoint temperature for laser 2, °C. - Valid range: [15.0 … 40.0] °C. - current1: Drive current for laser 1, mA. - Valid range: [15.0 … 60.0] mA. - current2: Drive current for laser 2, mA. - Valid range: [15.0 … 60.0] mA. - - Raises: - ValidationError: If any parameter is out of range. - CommunicationError: If the command cannot be sent. - """ - validated = ParameterValidator.validate_manual_mode_params( - temp1, temp2, current1, current2 + """Send manual setpoints and remember them for post-reset restore.""" + values = ParameterValidator.validate_manual_mode_params( + temp1=temp1, + temp2=temp2, + current1=current1, + current2=current2, ) self._message_id = (self._message_id + 1) & 0xFFFF - cmd = Protocol.encode_decode_enable( - temp1=validated['temp1'], - temp2=validated['temp2'], - current1=validated['current1'], - current2=validated['current2'], + command = Protocol.encode_decode_enable( + temp1=values["temp1"], + temp2=values["temp2"], + current1=values["current1"], + current2=values["current2"], pi_coeff1_p=self._pi1_p, pi_coeff1_i=self._pi1_i, pi_coeff2_p=self._pi2_p, pi_coeff2_i=self._pi2_i, message_id=self._message_id, ) - self._send_and_read_state(cmd) - self._last_temp1 = validated['temp1'] - self._last_temp2 = validated['temp2'] - self._last_current1 = validated['current1'] - self._last_current2 = validated['current2'] - logger.debug("Manual mode set: T1=%.2f T2=%.2f I1=%.2f I2=%.2f", - validated['temp1'], validated['temp2'], - validated['current1'], validated['current2']) + self._send_and_expect_ok(command) + self._last_temp1 = values["temp1"] + self._last_temp2 = values["temp2"] + self._last_current1 = values["current1"] + self._last_current2 = values["current2"] - def start_variation( + def reset(self) -> None: + """Send DEFAULT_ENABLE and require an error-free acknowledgement.""" + self._send_and_expect_ok(Protocol.encode_default_enable()) + logger.info("Device reset command sent") + + def configure_ad9102( self, - variation_type: VariationType, - params: dict, - ) -> None: - """ - Start a parameter variation task. + *, + enabled: bool, + use_sram: bool, + triangle: bool = False, + saw_step: int = 1, + pat_period_base: int = 2, + pat_period: int = 0xFFFF, + sample_count: int = 16, + hold_cycles: int = 1, + amplitude: int = 8191, + use_amplitude_format: bool = False, + ) -> int: + """Configure the AD9102 signal generator in saw or generated-SRAM mode.""" + if use_sram: + sample_count = self._validate_int_range( + sample_count, + "sample_count", + AD9102_SRAM_SAMPLE_MIN, + AD9102_SRAM_SAMPLE_MAX, + ) + if use_amplitude_format: + amplitude = self._validate_int_range( + amplitude, + "amplitude", + AD9102_SRAM_AMPLITUDE_MIN, + AD9102_SRAM_AMPLITUDE_MAX, + ) + command = Protocol.encode_ad9102_control( + enabled=enabled, + triangle=triangle, + sram_mode=True, + alt_format=True, + param0=amplitude, + param1=sample_count, + ) + else: + hold_cycles = self._validate_int_range( + hold_cycles, + "hold_cycles", + AD9102_SRAM_HOLD_MIN, + AD9102_SRAM_HOLD_MAX, + ) + command = Protocol.encode_ad9102_control( + enabled=enabled, + triangle=triangle, + sram_mode=True, + alt_format=False, + param0=sample_count, + param1=hold_cycles, + ) + else: + saw_step = self._validate_int_range( + saw_step, + "saw_step", + AD9102_SAW_STEP_MIN, + AD9102_SAW_STEP_MAX, + ) + pat_period_base = self._validate_int_range( + pat_period_base, + "pat_period_base", + AD9102_PAT_BASE_MIN, + AD9102_PAT_BASE_MAX, + ) + pat_period = self._validate_int_range( + pat_period, + "pat_period", + AD9102_PAT_PERIOD_MIN, + AD9102_PAT_PERIOD_MAX, + ) + param0 = ((pat_period_base & 0x0F) << 8) | (saw_step & 0xFF) + command = Protocol.encode_ad9102_control( + enabled=enabled, + triangle=triangle, + sram_mode=False, + alt_format=False, + param0=param0, + param1=pat_period, + ) - Args: - variation_type: Which parameter to vary - (:class:`VariationType.CHANGE_CURRENT_LD1` or - :class:`VariationType.CHANGE_CURRENT_LD2`). - params: Dictionary with the following keys: + detail = self._send_and_expect_ok(command) + logger.info("AD9102 configured: sram=%s triangle=%s enabled=%s", use_sram, triangle, enabled) + return detail - - ``min_value`` – minimum value of the varied parameter. - - ``max_value`` – maximum value of the varied parameter. - - ``step`` – step size. - - ``time_step`` – discretisation time step, µs [20 … 100]. - - ``delay_time``– delay between pulses, ms [3 … 10]. - - ``static_temp1`` – fixed temperature for laser 1, °C. - - ``static_temp2`` – fixed temperature for laser 2, °C. - - ``static_current1`` – fixed current for laser 1, mA. - - ``static_current2`` – fixed current for laser 2, mA. + def configure_ad9102_simple( + self, + *, + enabled: bool, + use_sram: bool, + triangle: bool, + frequency_hz: int, + amplitude: int = 8191, + ) -> dict[str, float | int | bool]: + """Configure AD9102 using simplified frequency/shape controls.""" + if use_sram: + amplitude = self._validate_int_range( + amplitude, + "amplitude", + AD9102_SRAM_AMPLITUDE_MIN, + AD9102_SRAM_AMPLITUDE_MAX, + ) + sample_count, actual_frequency_hz = ad9102_sram_sample_count_from_frequency_hz( + frequency_hz=frequency_hz, + hold_cycles=DEFAULT_AD9102_HOLD_CYCLES, + ) + detail = self.configure_ad9102( + enabled=enabled, + use_sram=True, + triangle=triangle, + sample_count=sample_count, + amplitude=amplitude, + use_amplitude_format=True, + ) + return { + "detail": detail, + "actual_frequency_hz": actual_frequency_hz, + "sample_count": sample_count, + "hold_cycles": DEFAULT_AD9102_HOLD_CYCLES, + "amplitude_applied": True, + } - Raises: - ValidationError: If any parameter fails validation. - CommunicationError: If the command cannot be sent. - """ - # Validate variation-specific params - validated = ParameterValidator.validate_variation_params( - params, variation_type + saw_step, actual_frequency_hz = ad9102_saw_step_from_frequency_hz( + triangle=triangle, + frequency_hz=frequency_hz, ) - - # Validate static parameters - static_temp1 = ParameterValidator.validate_temperature( - params.get('static_temp1', 25.0), 'static_temp1' + detail = self.configure_ad9102( + enabled=enabled, + use_sram=False, + triangle=triangle, + saw_step=saw_step, + pat_period_base=DEFAULT_AD9102_PAT_BASE, + pat_period=DEFAULT_AD9102_PAT_PERIOD, ) - static_temp2 = ParameterValidator.validate_temperature( - params.get('static_temp2', 25.0), 'static_temp2' - ) - static_current1 = ParameterValidator.validate_current( - params.get('static_current1', 30.0), 'static_current1' - ) - static_current2 = ParameterValidator.validate_current( - params.get('static_current2', 30.0), 'static_current2' - ) - - # Map VariationType → protocol TaskType - task_type_map = { - VariationType.CHANGE_CURRENT_LD1: ProtoTaskType.CHANGE_CURRENT_LD1, - VariationType.CHANGE_CURRENT_LD2: ProtoTaskType.CHANGE_CURRENT_LD2, - VariationType.CHANGE_TEMPERATURE_LD1: ProtoTaskType.CHANGE_TEMPERATURE_LD1, - VariationType.CHANGE_TEMPERATURE_LD2: ProtoTaskType.CHANGE_TEMPERATURE_LD2, + return { + "detail": detail, + "actual_frequency_hz": actual_frequency_hz, + "saw_step": saw_step, + "amplitude_applied": False, } - proto_task = task_type_map[validated['variation_type']] - cmd = Protocol.encode_task_enable( - task_type=proto_task, - static_temp1=static_temp1, - static_temp2=static_temp2, - static_current1=static_current1, - static_current2=static_current2, - min_value=validated['min_value'], - max_value=validated['max_value'], - step=validated['step'], - time_step=validated['time_step'], - delay_time=validated['delay_time'], - message_id=self._message_id, - pi_coeff1_p=self._pi1_p, - pi_coeff1_i=self._pi1_i, - pi_coeff2_p=self._pi2_p, - pi_coeff2_i=self._pi2_i, + def configure_ad9833(self, *, enabled: bool, triangle: bool, frequency_word: int) -> None: + """Configure the AD9833 generator using its raw 28-bit frequency word.""" + frequency_word = self._validate_int_range( + frequency_word, + "frequency_word", + AD9833_FREQ_WORD_MIN, + AD9833_FREQ_WORD_MAX, ) - self._send_and_read_state(cmd) - logger.info("Variation task started: type=%s min=%.3f max=%.3f step=%.3f", - validated['variation_type'].name, - validated['min_value'], - validated['max_value'], - validated['step']) - - def stop_task(self) -> None: - """Stop the current task and restore manual mode. - - Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last - known manual-mode parameters. This two-step sequence matches the - original firmware protocol: after DEFAULT_ENABLE the board is in a - reset state and must receive DECODE_ENABLE before it can respond to - TRANS_ENABLE data requests again. - """ - cmd_reset = Protocol.encode_default_enable() - self._send_and_read_state(cmd_reset) - logger.info("Task stopped (DEFAULT_ENABLE sent)") - - # Restore manual mode so the board is ready for TRANS_ENABLE requests - self._message_id = (self._message_id + 1) & 0xFFFF - cmd_restore = Protocol.encode_decode_enable( - temp1=self._last_temp1, - temp2=self._last_temp2, - current1=self._last_current1, - current2=self._last_current2, - pi_coeff1_p=self._pi1_p, - pi_coeff1_i=self._pi1_i, - pi_coeff2_p=self._pi2_p, - pi_coeff2_i=self._pi2_i, - message_id=self._message_id, + self._send_and_expect_ok( + Protocol.encode_ad9833_control( + enabled=enabled, + triangle=triangle, + frequency_word=frequency_word, + ) ) - self._send_and_read_state(cmd_restore) - logger.info("Manual mode restored after task stop") + logger.info("AD9833 configured: enabled=%s triangle=%s word=%d", enabled, triangle, frequency_word) - def get_measurements(self) -> Optional[Measurements]: - """ - Request and return the latest measurements from the device. + def configure_ad9833_frequency(self, *, enabled: bool, triangle: bool, frequency_hz: int) -> int: + """Configure AD9833 using output frequency in hertz for a 20 MHz master clock.""" + frequency_hz = self._validate_int_range( + frequency_hz, + "frequency_hz", + AD9833_OUTPUT_FREQ_MIN_HZ, + AD9833_OUTPUT_FREQ_MAX_HZ, + ) + frequency_word = int(round(frequency_hz * (1 << 28) / AD9833_MCLK_HZ)) + if frequency_word < AD9833_FREQ_WORD_MIN: + frequency_word = AD9833_FREQ_WORD_MIN + if frequency_word > AD9833_FREQ_WORD_MAX: + frequency_word = AD9833_FREQ_WORD_MAX + self.configure_ad9833( + enabled=enabled, + triangle=triangle, + frequency_word=frequency_word, + ) + return frequency_word - Returns: - :class:`Measurements` dataclass, or None if no data available. + def pulse_ds1809(self, *, increment: bool, count: int, pulse_ms: int) -> None: + """Pulse the DS1809 digital potentiometer in one direction.""" + if not increment and count: + decrement = True + else: + decrement = False + count = self._validate_int_range(count, "count", DS1809_COUNT_MIN, DS1809_COUNT_MAX) + pulse_ms = self._validate_int_range( + pulse_ms, + "pulse_ms", + DS1809_PULSE_MS_MIN, + DS1809_PULSE_MS_MAX, + ) + self._send_and_expect_ok( + Protocol.encode_ds1809_control( + increment=increment, + decrement=decrement, + count=count, + pulse_ms=pulse_ms, + ) + ) + logger.info("DS1809 pulsed: increment=%s count=%d pulse_ms=%d", increment, count, pulse_ms) - Raises: - CommunicationError: On transport errors. - """ - cmd = Protocol.encode_trans_enable() - self._send(cmd) + def set_stm32_dac(self, *, enabled: bool, dac_code: int) -> None: + """Set the STM32 on-chip DAC code and output-enable state.""" + dac_code = self._validate_int_range( + dac_code, + "dac_code", + STM32_DAC_CODE_MIN, + STM32_DAC_CODE_MAX, + ) + self._send_and_expect_ok( + Protocol.encode_stm32_dac_control(enabled=enabled, dac_code=dac_code) + ) + logger.info("STM32 DAC configured: enabled=%s code=%d", enabled, dac_code) - raw = self._protocol.receive_raw(30) - if not raw or len(raw) != 30: - logger.warning("No data received from device") + def save_profile_to_sd(self, request: ProfileSaveRequest) -> None: + """Stream a rendered profile INI and optional waveform CSV to the device SD card.""" + if not isinstance(request, ProfileSaveRequest): + raise InvalidParameterError("request", "Value must be a ProfileSaveRequest instance") + + profile_name = ParameterValidator.validate_profile_name(request.profile_name) + if not isinstance(request.profile_text, str) or not request.profile_text.strip(): + raise InvalidParameterError("profile_text", "Value must not be empty") + if not isinstance(request.waveform_text, str): + raise InvalidParameterError("waveform_text", "Value must be a string") + + try: + profile_bytes = request.profile_text.encode("ascii") + waveform_bytes = request.waveform_text.encode("ascii") + except UnicodeEncodeError as exc: + raise InvalidParameterError( + "profile_text", + "Profile payload must contain ASCII text only", + ) from exc + + begin_sent = False + try: + self._send_and_expect_ok( + Protocol.encode_profile_save_begin( + profile_name=profile_name, + profile_text_bytes=len(profile_bytes), + waveform_text_bytes=len(waveform_bytes), + ) + ) + begin_sent = True + + for start in range(0, len(profile_bytes), PROFILE_SAVE_DATA_CHUNK_BYTES): + self._send_and_expect_ok( + Protocol.encode_profile_save_data( + section_id=PROFILE_SAVE_SECTION_PROFILE_TEXT, + chunk=profile_bytes[start:start + PROFILE_SAVE_DATA_CHUNK_BYTES], + ) + ) + + for start in range(0, len(waveform_bytes), PROFILE_SAVE_DATA_CHUNK_BYTES): + self._send_and_expect_ok( + Protocol.encode_profile_save_data( + section_id=PROFILE_SAVE_SECTION_WAVEFORM_TEXT, + chunk=waveform_bytes[start:start + PROFILE_SAVE_DATA_CHUNK_BYTES], + ) + ) + + self._send_and_expect_ok(Protocol.encode_profile_save_commit()) + except Exception: + if begin_sent: + try: + self._send_and_expect_ok(Protocol.encode_profile_save_cancel()) + except Exception as cancel_exc: # noqa: BLE001 + logger.warning("Profile save cancel failed: %s", cancel_exc) + raise + + logger.info( + "Profile saved to SD: name=%s waveform_bytes=%d", + profile_name, + len(waveform_bytes), + ) + + def upload_ad9102_waveform(self, samples: Sequence[int]) -> None: + """Upload and commit a custom AD9102 waveform from signed 14-bit samples.""" + if not samples: + raise InvalidParameterError("samples", "At least two samples are required") + sample_list = [self._validate_wave_sample(sample, index) for index, sample in enumerate(samples)] + sample_count = len(sample_list) + if not AD9102_SRAM_SAMPLE_MIN <= sample_count <= AD9102_SRAM_SAMPLE_MAX: + raise InvalidParameterError( + "samples", + f"Sample count must be in range [{AD9102_SRAM_SAMPLE_MIN}, {AD9102_SRAM_SAMPLE_MAX}]", + ) + + self._send_and_expect_ok(Protocol.encode_ad9102_wave_begin(sample_count)) + for start in range(0, sample_count, AD9102_WAVE_MAX_CHUNK_SAMPLES): + chunk = sample_list[start:start + AD9102_WAVE_MAX_CHUNK_SAMPLES] + self._send_and_expect_ok(Protocol.encode_ad9102_wave_data(chunk)) + self._send_and_expect_ok(Protocol.encode_ad9102_wave_commit()) + logger.info("Uploaded AD9102 waveform with %d samples", sample_count) + + def cancel_ad9102_waveform_upload(self) -> None: + """Cancel an in-progress AD9102 custom waveform upload.""" + self._send_and_expect_ok(Protocol.encode_ad9102_wave_cancel()) + logger.info("Cancelled AD9102 waveform upload") + + def get_measurements(self) -> Measurements | None: + """Request one telemetry frame from the device.""" + self._send(Protocol.encode_trans_enable()) + raw = self._transport.read(GET_DATA_TOTAL_LENGTH) + if len(raw) != GET_DATA_TOTAL_LENGTH: + logger.warning("Expected %d telemetry bytes, got %d", GET_DATA_TOTAL_LENGTH, len(raw)) return None - response = Protocol.decode_response(raw) - measurements = response.to_measurements() + measurements = Protocol.decode_response(raw) self._last_measurements = measurements - - if self._on_data: + if self._on_data is not None: self._on_data(measurements) - return measurements def get_status(self) -> DeviceStatus: - """ - Request and return the current device status. - - Returns: - :class:`DeviceStatus` with state and latest measurements. - - Raises: - CommunicationError: On transport errors. - """ - cmd = Protocol.encode_state() - self._send(cmd) - - raw = self._protocol.receive_raw(2) - if not raw or len(raw) < 2: + """Query the current two-byte firmware status word.""" + self._send(Protocol.encode_state()) + raw = self._transport.read(STATUS_RESPONSE_LENGTH) + if len(raw) != STATUS_RESPONSE_LENGTH: raise DeviceNotRespondingError() - state_code = Protocol.decode_state(raw) - - # Try to get measurements as well - measurements = self._last_measurements - + state, detail = Protocol.decode_status(raw) return DeviceStatus( - state=DeviceState(state_code) if state_code in DeviceState._value2member_map_ - else DeviceState.ERROR, - measurements=measurements, + state=state, + detail=detail, + measurements=self._last_measurements, is_connected=self.is_connected, last_command_id=self._message_id, - error_message=Protocol.state_to_description(f"{state_code:04x}") - if state_code != 0 else None, + error_message=Protocol.state_to_description(state), ) - def reset(self) -> None: - """Send a hardware reset command to the device.""" - cmd = Protocol.encode_default_enable() - self._send_and_read_state(cmd) - logger.info("Device reset command sent") - - # ---- Internal helpers ------------------------------------------------- - - def _send(self, cmd: bytes) -> None: - """Send command bytes and wait for the device to process.""" + def _send(self, data: bytes) -> None: if not self.is_connected: raise CommunicationError("Not connected to device. Call connect() first.") - self._protocol.send_raw(cmd) + self._transport.send(data) time.sleep(WAIT_AFTER_SEND_SEC) - def _send_and_read_state(self, cmd: bytes) -> int: - """Send command and read the 2-byte STATE response the device always returns. + def _send_and_expect_ok(self, data: bytes) -> int: + self._send(data) + raw = self._transport.read(STATUS_RESPONSE_LENGTH) + if len(raw) != STATUS_RESPONSE_LENGTH: + raise DeviceNotRespondingError() - Commands DECODE_ENABLE, TASK_ENABLE and DEFAULT_ENABLE each trigger a - STATE reply from the firmware. If we don't consume those bytes here, - they accumulate in the serial buffer and corrupt the next DATA read. + state, detail = Protocol.decode_status(raw) + if state != DeviceState.OK: + combined_code = int(state) | (detail << 8) + raise DeviceStateError( + combined_code, + Protocol.state_to_description(state), + ) + return detail - Returns the decoded state code (0x0000 = OK). - """ - self._send(cmd) - raw = self._protocol.receive_raw(2) - if raw and len(raw) == 2: - state = Protocol.decode_state(raw) - logger.debug("STATE response after command: 0x%04x", state) - return state - return 0 + @staticmethod + def _validate_int_range(value: int, name: str, minimum: int, maximum: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise InvalidParameterError(name, "Value must be an integer") + if not minimum <= value <= maximum: + raise InvalidParameterError(name, f"Value must be in range [{minimum}, {maximum}]") + return value - # ---- Context manager support ----------------------------------------- + @staticmethod + def _validate_wave_sample(value: int, index: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise InvalidParameterError(f"samples[{index}]", "Value must be an integer") + if not AD9102_WAVE_SAMPLE_MIN <= value <= AD9102_WAVE_SAMPLE_MAX: + raise InvalidParameterError( + f"samples[{index}]", + f"Value must be in range [{AD9102_WAVE_SAMPLE_MIN}, {AD9102_WAVE_SAMPLE_MAX}]", + ) + return value - def __enter__(self): + def __enter__(self) -> "LaserController": self.connect() return self - def __exit__(self, exc_type, exc_val, exc_tb): - # Always try to stop any running task before closing the port. - # If we don't, the board stays in TASK state and ignores all future - # commands until its power is cycled. + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: if self.is_connected: - try: - self.stop_task() - except Exception: - pass - self.disconnect() - return False \ No newline at end of file + self.disconnect() + return False diff --git a/laser_control/example_usage.py b/laser_control/example_usage.py index de08fea..eb8180e 100644 --- a/laser_control/example_usage.py +++ b/laser_control/example_usage.py @@ -1,15 +1,8 @@ -""" -Example: how to embed laser_control into any Python application. - -Run: - python3 laser_control/example_usage.py -""" +"""Minimal examples for embedding laser_control into another Python app.""" import sys -import time from laser_control import ( LaserController, - VariationType, ValidationError, CommunicationError, ) @@ -42,47 +35,6 @@ def example_manual_mode(port: str = None): except CommunicationError as e: print(f"Communication error: {e}") - -def example_variation_mode(port: str = None): - """Variation mode: sweep current of laser 1.""" - collected = [] - - def on_measurement(m): - collected.append(m) - print(f" t={m.timestamp.isoformat(timespec='milliseconds')} " - f"I1={m.current1:.3f} mA T1={m.temp1:.2f} °C") - - with LaserController(port=port, on_data=on_measurement) as ctrl: - try: - ctrl.start_variation( - variation_type=VariationType.CHANGE_CURRENT_LD1, - params={ - 'min_value': 33.0, # mA (matches firmware initial current) - 'max_value': 60.0, # mA - 'step': 0.05, # mA - 'time_step': 50, # µs (20-100), Word 6 = time_step × 100 - 'delay_time': 10, # ms (3-10), Word 10 = Tau - 'static_temp1': 28.0, - 'static_temp2': 28.9, - 'static_current1': 33.0, - 'static_current2': 35.0, - } - ) - print("Variation task started. Collecting data for 2 s...") - deadline = time.monotonic() + 2.0 - while time.monotonic() < deadline: - ctrl.get_measurements() - time.sleep(0.15) - - ctrl.stop_task() - print(f"Done. Collected {len(collected)} measurements.") - - except ValidationError as e: - print(f"Parameter validation error: {e}") - except CommunicationError as e: - print(f"Communication error: {e}") - - def example_embed_in_app(): """ Minimal embedding pattern for use inside another application. @@ -105,6 +57,3 @@ if __name__ == '__main__': print("=== Manual mode example ===") example_manual_mode(port) - - print("\n=== Variation mode example ===") - example_variation_mode(port) \ No newline at end of file diff --git a/laser_control/gui/__init__.py b/laser_control/gui/__init__.py new file mode 100644 index 0000000..bafd178 --- /dev/null +++ b/laser_control/gui/__init__.py @@ -0,0 +1 @@ +"""PyQt GUI package for the laser-control application.""" diff --git a/laser_control/gui/dialogs.py b/laser_control/gui/dialogs.py new file mode 100644 index 0000000..b6d366d --- /dev/null +++ b/laser_control/gui/dialogs.py @@ -0,0 +1,74 @@ +"""Small dialogs used by the main laser-control window.""" + +from __future__ import annotations + +from PyQt6.QtCore import QRegularExpression +from PyQt6.QtGui import QRegularExpressionValidator +from PyQt6.QtWidgets import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QLabel, + QLineEdit, + QVBoxLayout, +) + +from laser_control.constants import PROFILE_NAME_ALLOWED_PATTERN, PROFILE_NAME_MAX_LENGTH + + +class ProfileSaveDialog(QDialog): + """Ask the user for a short profile name before saving it to the device SD card.""" + + def __init__(self, *, custom_waveform_available: bool, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle("Сохранить профиль на SD") + self.setModal(True) + + layout = QVBoxLayout(self) + + note = QLabel( + "Введите короткое имя профиля для маленького LCD на устройстве. " + f"Допустимо до {PROFILE_NAME_MAX_LENGTH} ASCII-символов: буквы, цифры, пробел, '-' и '_'." + ) + note.setWordWrap(True) + + self._name_edit = QLineEdit(self) + self._name_edit.setPlaceholderText("Например: Factory Saw") + self._name_edit.setMaxLength(PROFILE_NAME_MAX_LENGTH) + self._name_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(PROFILE_NAME_ALLOWED_PATTERN), self) + ) + + self._waveform_checkbox = QCheckBox( + "Сохранить и пользовательскую форму из вкладки «Своя форма»", + self, + ) + self._waveform_checkbox.setVisible(custom_waveform_available) + + self._buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + parent=self, + ) + self._buttons.accepted.connect(self.accept) + self._buttons.rejected.connect(self.reject) + self._buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False) + + self._name_edit.textChanged.connect(self._update_accept_state) + + layout.addWidget(note) + layout.addWidget(self._name_edit) + layout.addWidget(self._waveform_checkbox) + layout.addWidget(self._buttons) + + def profile_name(self) -> str: + """Return the trimmed display name entered by the user.""" + return self._name_edit.text().strip() + + def include_custom_waveform(self) -> bool: + """Return True when a valid custom waveform should be saved with the profile.""" + return self._waveform_checkbox.isVisible() and self._waveform_checkbox.isChecked() + + def _update_accept_state(self) -> None: + self._buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled( + bool(self.profile_name()) + ) diff --git a/laser_control/gui/main.py b/laser_control/gui/main.py new file mode 100644 index 0000000..23e36ff --- /dev/null +++ b/laser_control/gui/main.py @@ -0,0 +1,32 @@ +"""Application entry point for the PyQt-based laser-control GUI.""" + +from __future__ import annotations + +import os +import sys + +from PyQt6.QtWidgets import QApplication +import pyqtgraph as pg + +from .theme import apply_theme +from .window import MainWindow + + +def main() -> int: + """Run the GUI event loop.""" + os.environ.setdefault("PYQTGRAPH_QT_LIB", "PyQt6") + + app = QApplication(sys.argv) + pg.setConfigOptions(antialias=True, background="#0f1720", foreground="#dce6f2") + apply_theme(app) + + window = MainWindow(auto_connect=True) + screen = app.primaryScreen() + if screen is not None: + window.setGeometry(screen.availableGeometry()) + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/laser_control/gui/sections.py b/laser_control/gui/sections.py new file mode 100644 index 0000000..63f1235 --- /dev/null +++ b/laser_control/gui/sections.py @@ -0,0 +1,563 @@ +"""Small UI builders used by the main laser-control window.""" + +from __future__ import annotations + +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QPlainTextEdit, + QPushButton, + QSizePolicy, + QSpinBox, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from laser_control.constants import ( + AD9102_PAT_BASE_MAX, + AD9102_PAT_BASE_MIN, + AD9102_PAT_PERIOD_MAX, + AD9102_PAT_PERIOD_MIN, + AD9102_SAW_STEP_MAX, + AD9102_SAW_STEP_MIN, + AD9102_SRAM_AMPLITUDE_MAX, + AD9102_SRAM_AMPLITUDE_MIN, + AD9102_SRAM_HOLD_MAX, + AD9102_SRAM_HOLD_MIN, + AD9102_SRAM_SAMPLE_MAX, + AD9102_SRAM_SAMPLE_MIN, + CURRENT_MAX_MA, + CURRENT_MIN_MA, + DEFAULT_AD9102_AMPLITUDE, + DEFAULT_AD9102_SAW_FREQUENCY_HZ, + DEFAULT_AD9102_SRAM_FREQUENCY_HZ, + DEFAULT_AD9102_HOLD_CYCLES, + DEFAULT_AD9102_PAT_BASE, + DEFAULT_AD9102_PAT_PERIOD, + DEFAULT_AD9102_SAMPLE_COUNT, + DEFAULT_AD9102_SAW_STEP, + DEFAULT_AD9833_FREQUENCY_HZ, + DEFAULT_CURRENT1_MA, + DEFAULT_CURRENT2_MA, + DEFAULT_DS1809_COUNT, + DEFAULT_DS1809_PROFILE_POSITION, + DEFAULT_DS1809_PULSE_MS, + DEFAULT_STM32_DAC_CODE, + DEFAULT_TEMP1_C, + DEFAULT_TEMP2_C, + DS1809_COUNT_MAX, + DS1809_COUNT_MIN, + DS1809_PROFILE_POSITION_MAX, + DS1809_PROFILE_POSITION_MIN, + DS1809_PULSE_MS_MAX, + DS1809_PULSE_MS_MIN, + AD9833_MCLK_HZ, + AD9833_OUTPUT_FREQ_MAX_HZ, + AD9833_OUTPUT_FREQ_MIN_HZ, + STM32_DAC_CODE_MAX, + STM32_DAC_CODE_MIN, + TEMP_MAX_C, + TEMP_MIN_C, +) + + +def _double_spinbox( + minimum: float, + maximum: float, + value: float, + *, + decimals: int = 2, + step: float = 0.1, + suffix: str = "", +) -> QDoubleSpinBox: + box = QDoubleSpinBox() + box.setRange(minimum, maximum) + box.setDecimals(decimals) + box.setSingleStep(step) + box.setValue(value) + if suffix: + box.setSuffix(suffix) + return box + + +def _int_spinbox(minimum: int, maximum: int, value: int, *, suffix: str = "") -> QSpinBox: + box = QSpinBox() + box.setRange(minimum, maximum) + box.setValue(value) + if suffix: + box.setSuffix(suffix) + return box + + +def _expanding_button(label: str, *, primary: bool = False) -> QPushButton: + button = QPushButton(label) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + if primary: + button.setObjectName("primaryButton") + return button + + +def build_manual_group(owner) -> QGroupBox: + """Create manual control inputs.""" + group = QGroupBox("Ручной режим") + layout = QFormLayout(group) + layout.setHorizontalSpacing(12) + layout.setVerticalSpacing(8) + + owner._manual_temp1 = _double_spinbox(TEMP_MIN_C, TEMP_MAX_C, DEFAULT_TEMP1_C, suffix=" °C") + owner._manual_temp2 = _double_spinbox(TEMP_MIN_C, TEMP_MAX_C, DEFAULT_TEMP2_C, suffix=" °C") + owner._manual_current1 = _double_spinbox( + CURRENT_MIN_MA, + CURRENT_MAX_MA, + DEFAULT_CURRENT1_MA, + decimals=3, + step=0.05, + suffix=" мА", + ) + owner._manual_current2 = _double_spinbox( + CURRENT_MIN_MA, + CURRENT_MAX_MA, + DEFAULT_CURRENT2_MA, + decimals=3, + step=0.05, + suffix=" мА", + ) + + layout.addRow("Температура лазера 1", owner._manual_temp1) + layout.addRow("Температура лазера 2", owner._manual_temp2) + layout.addRow("Ток лазера 1", owner._manual_current1) + layout.addRow("Ток лазера 2", owner._manual_current2) + + owner._apply_manual_button = _expanding_button("Применить", primary=True) + owner._apply_manual_button.clicked.connect(owner._on_apply_manual) + layout.addRow(owner._apply_manual_button) + return group + + +def build_device_group(owner) -> QGroupBox: + """Create compact tabs for supported peripheral commands.""" + group = QGroupBox("Периферия") + layout = QVBoxLayout(group) + + tabs = QTabWidget(group) + tabs.addTab(_build_ad9102_tab(owner), "Генератор AD9102") + tabs.addTab(_build_ad9833_tab(owner), "Генератор AD9833") + tabs.addTab(_build_aux_tab(owner), "Выходы и DS1809") + tabs.addTab(_build_wave_tab(owner), "Своя форма") + layout.addWidget(tabs) + return group + + +def _build_ad9102_tab(owner) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(10) + + note = QLabel( + "AD9102 в этой прошивке умеет два режима. " + "Первый: встроенная пила или треугольник самого AD9102. " + "Второй: STM32 сама строит пилу или треугольник из точек и записывает их в память AD9102. " + "Для упрощённого режима ниже задаются понятные величины, а регистровые поля спрятаны в расширенных настройках." + ) + note.setWordWrap(True) + note.setObjectName("captionLabel") + + basic_group = QGroupBox("Основные настройки") + basic_layout = QFormLayout(basic_group) + basic_layout.setHorizontalSpacing(12) + basic_layout.setVerticalSpacing(8) + + owner._ad9102_enable = QCheckBox("Подать сигнал на выход") + owner._ad9102_enable.setChecked(True) + owner._ad9102_mode = QComboBox() + owner._ad9102_mode.addItem("Встроенная пила/треугольник AD9102", "saw") + owner._ad9102_mode.addItem("Пила/треугольник через память AD9102", "sram") + owner._ad9102_shape = QComboBox() + owner._ad9102_shape.addItem("Пила", "saw") + owner._ad9102_shape.addItem("Треугольник", "triangle") + owner._ad9102_shape.setCurrentIndex(1) + owner._ad9102_frequency_hz = _int_spinbox( + 1, + DEFAULT_AD9102_SRAM_FREQUENCY_HZ, + DEFAULT_AD9102_SAW_FREQUENCY_HZ, + suffix=" Гц", + ) + owner._ad9102_frequency_hz.setSingleStep(100) + owner._ad9102_frequency_hz.setGroupSeparatorShown(True) + owner._ad9102_basic_hint = QLabel() + owner._ad9102_basic_hint.setWordWrap(True) + owner._ad9102_basic_hint.setObjectName("captionLabel") + owner._ad9102_preview = QLabel("Реальная частота: —") + owner._ad9102_preview.setObjectName("valueLabel") + owner._ad9102_advanced_toggle = QCheckBox("Показать расширенные параметры AD9102") + + owner._ad9102_saw_step = _int_spinbox( + AD9102_SAW_STEP_MIN, + AD9102_SAW_STEP_MAX, + DEFAULT_AD9102_SAW_STEP, + ) + owner._ad9102_pat_base = _int_spinbox( + AD9102_PAT_BASE_MIN, + AD9102_PAT_BASE_MAX, + DEFAULT_AD9102_PAT_BASE, + ) + owner._ad9102_pat_period = _int_spinbox( + AD9102_PAT_PERIOD_MIN, + AD9102_PAT_PERIOD_MAX, + DEFAULT_AD9102_PAT_PERIOD, + ) + owner._ad9102_sample_count = _int_spinbox( + AD9102_SRAM_SAMPLE_MIN, + AD9102_SRAM_SAMPLE_MAX, + DEFAULT_AD9102_SAMPLE_COUNT, + ) + owner._ad9102_hold_cycles = _int_spinbox( + AD9102_SRAM_HOLD_MIN, + AD9102_SRAM_HOLD_MAX, + DEFAULT_AD9102_HOLD_CYCLES, + ) + owner._ad9102_amplitude = _int_spinbox( + AD9102_SRAM_AMPLITUDE_MIN, + AD9102_SRAM_AMPLITUDE_MAX, + DEFAULT_AD9102_AMPLITUDE, + ) + owner._ad9102_use_amplitude = QCheckBox( + "Использовать формат \"размах + число точек\" вместо \"пауза + число точек\"" + ) + owner._ad9102_mode.currentIndexChanged.connect(owner._on_ad9102_mode_changed) + owner._ad9102_shape.currentIndexChanged.connect(owner._update_ad9102_form) + owner._ad9102_frequency_hz.valueChanged.connect(owner._update_ad9102_form) + owner._ad9102_advanced_toggle.toggled.connect(owner._update_ad9102_form) + owner._ad9102_use_amplitude.toggled.connect(owner._update_ad9102_form) + owner._ad9102_sample_count.valueChanged.connect(owner._update_ad9102_form) + owner._ad9102_hold_cycles.valueChanged.connect(owner._update_ad9102_form) + owner._ad9102_saw_step.valueChanged.connect(owner._update_ad9102_form) + owner._ad9102_amplitude.valueChanged.connect(owner._update_ad9102_form) + owner._ad9102_use_amplitude.setChecked(True) + + basic_layout.addRow(owner._ad9102_enable) + basic_layout.addRow("Режим генерации", owner._ad9102_mode) + basic_layout.addRow("Форма сигнала", owner._ad9102_shape) + basic_layout.addRow("Частота сигнала", owner._ad9102_frequency_hz) + basic_layout.addRow("Размах сигнала в режиме памяти (0..8191)", owner._ad9102_amplitude) + basic_layout.addRow(owner._ad9102_preview) + basic_layout.addRow(owner._ad9102_basic_hint) + + owner._ad9102_advanced_group = QGroupBox("Расширенные параметры") + advanced_layout = QFormLayout(owner._ad9102_advanced_group) + advanced_layout.setHorizontalSpacing(12) + advanced_layout.setVerticalSpacing(8) + advanced_layout.addRow("Скорость нарастания пилы (1..63)", owner._ad9102_saw_step) + advanced_layout.addRow("Масштаб периода (0..15)", owner._ad9102_pat_base) + advanced_layout.addRow("Длина периода (0..65535)", owner._ad9102_pat_period) + advanced_layout.addRow("Число точек формы (2..4096)", owner._ad9102_sample_count) + advanced_layout.addRow("Пауза на каждой точке (0..15)", owner._ad9102_hold_cycles) + advanced_layout.addRow(owner._ad9102_use_amplitude) + + layout.addWidget(note) + layout.addWidget(basic_group) + layout.addWidget(owner._ad9102_advanced_toggle) + layout.addWidget(owner._ad9102_advanced_group) + + owner._apply_ad9102_button = _expanding_button("Применить настройки генератора", primary=True) + owner._apply_ad9102_button.clicked.connect(owner._on_apply_ad9102) + layout.addWidget(owner._apply_ad9102_button) + layout.addStretch(1) + + owner._ad9102_saw_step.setToolTip( + "Код шага нарастания пилообразного сигнала. Диапазон 1..63. " + "Чем больше значение, тем быстрее растёт сигнал внутри одного периода." + ) + owner._ad9102_pat_base.setToolTip( + "Грубый масштаб периода повторения. Диапазон 0..15. " + "Используется вместе с длиной периода и задаёт базу времени генератора." + ) + owner._ad9102_pat_period.setToolTip( + "Точная длина периода повторения. Диапазон 0..65535. " + "Совместно с масштабом периода определяет, как часто форма начинается заново." + ) + owner._ad9102_sample_count.setToolTip( + "Количество отсчётов формы в памяти SRAM. Диапазон 2..4096. " + "Один период в режиме памяти состоит из этого числа точек." + ) + owner._ad9102_hold_cycles.setToolTip( + "Количество внутренних циклов удержания одной точки формы. Диапазон 0..15. " + "Чем больше значение, тем дольше каждая точка удерживается перед переходом к следующей." + ) + owner._ad9102_amplitude.setToolTip( + "Амплитудный коэффициент для режима, где STM32 сама строит пилу или треугольник " + "и записывает их в память AD9102. Диапазон 0..8191. Чем больше значение, тем больше размах сигнала." + ) + owner._ad9102_frequency_hz.setToolTip( + "Желаемая частота сигнала в герцах. " + "Интерфейс автоматически подберёт ближайшие поддерживаемые параметры AD9102." + ) + owner._ad9102_preview.setToolTip( + "Показывает, какая реальная частота получится после округления к поддерживаемым параметрам чипа." + ) + owner._ad9102_use_amplitude.setToolTip( + "Ограничение текущей короткой STM-команды: в режиме памяти можно передать " + "либо \"размах + число точек\", либо \"пауза + число точек\"." + ) + return tab + + +def _build_ad9833_tab(owner) -> QWidget: + tab = QWidget() + layout = QFormLayout(tab) + layout.setHorizontalSpacing(12) + layout.setVerticalSpacing(8) + + owner._ad9833_enable = QCheckBox("Подать сигнал на выход") + owner._ad9833_enable.setChecked(True) + owner._ad9833_shape = QComboBox() + owner._ad9833_shape.addItem("Синус", "sine") + owner._ad9833_shape.addItem("Треугольник", "triangle") + owner._ad9833_shape.setCurrentIndex(1) + owner._ad9833_frequency_hz = _int_spinbox( + AD9833_OUTPUT_FREQ_MIN_HZ, + AD9833_OUTPUT_FREQ_MAX_HZ, + DEFAULT_AD9833_FREQUENCY_HZ, + suffix=" Гц", + ) + owner._ad9833_frequency_hz.setSingleStep(1000) + owner._ad9833_frequency_hz.setGroupSeparatorShown(True) + owner._ad9833_frequency_hz.valueChanged.connect(owner._update_ad9833_preview) + owner._ad9833_word_preview = QLabel("Внутренний код: —") + owner._ad9833_word_preview.setObjectName("valueLabel") + + note = QLabel( + f"AD9833 тактируется от {AD9833_MCLK_HZ:,} Гц. " + f"Поэтому здесь задаётся сразу частота сигнала в герцах. " + f"Рабочий диапазон интерфейса: {AD9833_OUTPUT_FREQ_MIN_HZ:,}..{AD9833_OUTPUT_FREQ_MAX_HZ:,} Гц " + f"(до половины тактовой частоты). Внутренний код рассчитывается автоматически." + ) + note.setWordWrap(True) + note.setObjectName("captionLabel") + + layout.addRow(owner._ad9833_enable) + layout.addRow("Форма сигнала", owner._ad9833_shape) + layout.addRow( + f"Частота сигнала ({AD9833_OUTPUT_FREQ_MIN_HZ:,}..{AD9833_OUTPUT_FREQ_MAX_HZ:,} Гц)", + owner._ad9833_frequency_hz, + ) + layout.addRow("Внутренний код AD9833", owner._ad9833_word_preview) + layout.addRow(note) + + owner._apply_ad9833_button = _expanding_button("Применить настройки генератора", primary=True) + owner._apply_ad9833_button.clicked.connect(owner._on_apply_ad9833) + layout.addRow(owner._apply_ad9833_button) + owner._ad9833_frequency_hz.setToolTip( + "Частота выходного сигнала в герцах. " + "Интерфейс ограничен диапазоном 0..10 МГц для тактовой частоты 20 МГц." + ) + owner._ad9833_word_preview.setToolTip( + "28-битный frequency word, который будет автоматически передан в AD9833." + ) + return tab + + +def _build_aux_tab(owner) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(10) + + dac_group = QGroupBox("Аналоговый выход STM32 (PA4)") + dac_layout = QFormLayout(dac_group) + dac_layout.setHorizontalSpacing(12) + dac_layout.setVerticalSpacing(8) + + dac_note = QLabel( + "Это встроенный 12-битный ЦАП микроконтроллера STM32. " + "Диапазон кода: 0..4095. Это соответствует примерно 0..Vref+, " + "то есть обычно около 0..3.3 В." + ) + dac_note.setWordWrap(True) + dac_note.setObjectName("captionLabel") + + owner._stm32_dac_enable = QCheckBox("Подать напряжение на выход") + owner._stm32_dac_enable.setChecked(True) + owner._stm32_dac_code = _int_spinbox( + STM32_DAC_CODE_MIN, + STM32_DAC_CODE_MAX, + DEFAULT_STM32_DAC_CODE, + ) + owner._apply_stm32_dac_button = _expanding_button("Применить уровень выхода", primary=True) + owner._apply_stm32_dac_button.clicked.connect(owner._on_apply_stm32_dac) + dac_layout.addRow(dac_note) + dac_layout.addRow(owner._stm32_dac_enable) + dac_layout.addRow("Уровень выхода (0..4095)", owner._stm32_dac_code) + dac_layout.addRow(owner._apply_stm32_dac_button) + owner._stm32_dac_code.setToolTip( + "Код встроенного 12-битного ЦАП STM32. " + "0 = примерно 0 В, 4095 = примерно верхний предел питания ЦАП " + "(обычно около 3.3 В)." + ) + + ds_group = QGroupBox("Цифровой подстроечный резистор DS1809") + ds_layout = QFormLayout(ds_group) + ds_layout.setHorizontalSpacing(12) + ds_layout.setVerticalSpacing(8) + owner._ds1809_direction = QComboBox() + owner._ds1809_direction.addItem("Увеличить", "inc") + owner._ds1809_direction.addItem("Уменьшить", "dec") + owner._ds1809_count = _int_spinbox(DS1809_COUNT_MIN, DS1809_COUNT_MAX, DEFAULT_DS1809_COUNT) + owner._ds1809_pulse_ms = _int_spinbox( + DS1809_PULSE_MS_MIN, + DS1809_PULSE_MS_MAX, + DEFAULT_DS1809_PULSE_MS, + suffix=" мс", + ) + owner._pulse_ds1809_button = _expanding_button("Сделать шаги резистора", primary=True) + owner._pulse_ds1809_button.clicked.connect(owner._on_pulse_ds1809) + owner._ds1809_profile_apply = QCheckBox("Сохранять абсолютную позицию в профиль") + owner._ds1809_profile_apply.setChecked(True) + owner._ds1809_profile_position = _int_spinbox( + DS1809_PROFILE_POSITION_MIN, + DS1809_PROFILE_POSITION_MAX, + DEFAULT_DS1809_PROFILE_POSITION, + ) + ds_layout.addRow("Куда менять", owner._ds1809_direction) + ds_layout.addRow("Число шагов", owner._ds1809_count) + ds_layout.addRow("Длительность шага", owner._ds1809_pulse_ms) + ds_layout.addRow(owner._ds1809_profile_apply) + ds_layout.addRow("Позиция для профиля (от минимума)", owner._ds1809_profile_position) + ds_layout.addRow(owner._pulse_ds1809_button) + owner._ds1809_count.setToolTip("На сколько шагов изменить цифровой резистор.") + owner._ds1809_pulse_ms.setToolTip("Сколько миллисекунд длится один управляющий импульс.") + owner._ds1809_profile_apply.setToolTip( + "Если флажок включён, при сохранении профиля прошивка сначала загонит DS1809 в минимум, " + "а затем поднимет его до указанной позиции." + ) + owner._ds1809_profile_position.setToolTip( + "Абсолютная позиция DS1809 относительно минимального положения. " + "Используется только при сохранении профиля на SD." + ) + + layout.addWidget(dac_group) + layout.addWidget(ds_group) + layout.addStretch(1) + return tab + + +def _build_wave_tab(owner) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(10) + + note = QLabel( + "Здесь можно вручную загрузить свою форму сигнала для AD9102. " + "Каждое число - это одна точка формы. Допустимый диапазон: от -8192 до 8191." + ) + note.setWordWrap(True) + note.setObjectName("captionLabel") + layout.addWidget(note) + + owner._wave_info_label = QLabel("Отсчётов: 0") + owner._wave_info_label.setObjectName("valueLabel") + layout.addWidget(owner._wave_info_label) + + owner._wave_samples_box = QPlainTextEdit() + owner._wave_samples_box.setPlaceholderText("0 1024 2048 1024 0 -1024 -2048 -1024") + owner._wave_samples_box.setMinimumHeight(180) + owner._wave_samples_box.textChanged.connect(owner._on_wave_text_changed) + layout.addWidget(owner._wave_samples_box) + + buttons = QWidget() + buttons_layout = QHBoxLayout(buttons) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.setSpacing(8) + + owner._load_wave_file_button = _expanding_button("Открыть файл") + owner._upload_wave_button = _expanding_button("Загрузить форму", primary=True) + owner._cancel_wave_button = _expanding_button("Отменить загрузку") + owner._load_wave_file_button.clicked.connect(owner._on_load_wave_file) + owner._upload_wave_button.clicked.connect(owner._on_upload_waveform) + owner._cancel_wave_button.clicked.connect(owner._on_cancel_waveform) + + buttons_layout.addWidget(owner._load_wave_file_button) + buttons_layout.addWidget(owner._upload_wave_button) + buttons_layout.addWidget(owner._cancel_wave_button) + layout.addWidget(buttons) + return tab + + +def build_status_group(owner) -> QGroupBox: + """Create status and telemetry labels.""" + group = QGroupBox("Телеметрия и статус") + layout = QVBoxLayout(group) + layout.setSpacing(10) + + owner._status_header = QLabel("Отключено") + owner._status_header.setObjectName("statusError") + layout.addWidget(owner._status_header) + + grid = QGridLayout() + grid.setHorizontalSpacing(12) + grid.setVerticalSpacing(6) + + rows = [ + ("Порт", "_port_value"), + ("Статус", "_state_value"), + ("Доп. код", "_detail_value"), + ("ID сообщения", "_message_id_value"), + ("Температура 1", "_telemetry_temp1"), + ("Температура 2", "_telemetry_temp2"), + ("Фотодиод 1", "_telemetry_current1"), + ("Фотодиод 2", "_telemetry_current2"), + ("Внешняя температура 1", "_telemetry_temp_ext1"), + ("Внешняя температура 2", "_telemetry_temp_ext2"), + ("Питание 3.3 В", "_telemetry_3v3"), + ("Питание 5V1", "_telemetry_5v1"), + ("Питание 5V2", "_telemetry_5v2"), + ("Питание 7V0", "_telemetry_7v0"), + ] + + for row_index, (label_text, attr_name) in enumerate(rows): + label = QLabel(label_text) + label.setObjectName("captionLabel") + value = QLabel("—") + value.setObjectName("valueLabel") + setattr(owner, attr_name, value) + grid.addWidget(label, row_index, 0) + grid.addWidget(value, row_index, 1) + + layout.addLayout(grid) + + buttons = QWidget() + buttons_layout = QHBoxLayout(buttons) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.setSpacing(8) + + owner._reconnect_button = _expanding_button("Переподключить") + owner._reset_button = _expanding_button("Сброс") + owner._save_profile_button = _expanding_button("Сохранить профиль", primary=True) + owner._reconnect_button.clicked.connect(owner._on_reconnect) + owner._reset_button.clicked.connect(owner._on_reset_device) + owner._save_profile_button.clicked.connect(owner._on_save_profile) + buttons_layout.addWidget(owner._reconnect_button) + buttons_layout.addWidget(owner._reset_button) + layout.addWidget(buttons) + layout.addWidget(owner._save_profile_button) + return group + + +def build_log_group(owner) -> QGroupBox: + """Create compact runtime log output.""" + group = QGroupBox("Runtime Log") + layout = QVBoxLayout(group) + + owner._log_box = QTextEdit() + owner._log_box.setObjectName("logBox") + owner._log_box.setReadOnly(True) + owner._log_box.setMinimumHeight(180) + layout.addWidget(owner._log_box) + return group diff --git a/laser_control/gui/theme.py b/laser_control/gui/theme.py new file mode 100644 index 0000000..dec7c08 --- /dev/null +++ b/laser_control/gui/theme.py @@ -0,0 +1,156 @@ +"""Shared Qt theme for the laser-control desktop UI.""" + +from __future__ import annotations + +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication, QStyleFactory + + +_STYLESHEET = """ +QMainWindow { + background-color: #edf2f7; +} + +QWidget { + color: #17212b; + font-size: 13px; +} + +QGroupBox { + background-color: #ffffff; + border: 1px solid #d8e0ea; + border-radius: 14px; + margin-top: 14px; + padding: 12px; +} + +QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 6px; + color: #506274; + font-weight: 600; +} + +QPushButton { + background-color: #f7fafc; + border: 1px solid #c8d3df; + border-radius: 8px; + padding: 7px 12px; +} + +QPushButton:hover { + background-color: #edf3f9; +} + +QPushButton:pressed { + background-color: #e2ebf4; +} + +QPushButton#primaryButton { + background-color: #1f6feb; + border-color: #1f6feb; + color: #ffffff; + font-weight: 600; +} + +QPushButton#primaryButton:hover { + background-color: #2b7bf7; +} + +QPushButton:disabled { + color: #95a3b3; + background-color: #f3f6f9; + border-color: #dde5ee; +} + +QLabel#captionLabel { + color: #6b7b8d; +} + +QLabel#valueLabel { + color: #1f2937; + font-weight: 600; +} + +QLabel#statusOk { + color: #156f3d; + font-weight: 700; +} + +QLabel#statusError { + color: #b42318; + font-weight: 700; +} + +QDoubleSpinBox, +QSpinBox, +QComboBox, +QLineEdit, +QTextEdit, +QPlainTextEdit { + background-color: #ffffff; + border: 1px solid #c8d3df; + border-radius: 8px; + padding: 5px 8px; +} + +QDoubleSpinBox:focus, +QSpinBox:focus, +QComboBox:focus, +QLineEdit:focus, +QTextEdit:focus, +QPlainTextEdit:focus { + border: 1px solid #1f6feb; +} + +QTextEdit#logBox, +QPlainTextEdit { + font-family: "DejaVu Sans Mono"; + font-size: 12px; + background-color: #fbfdff; +} + +QTabWidget::pane { + border: 1px solid #d8e0ea; + border-radius: 10px; + background-color: #f8fbfe; + top: -1px; +} + +QTabBar::tab { + background-color: #eef4fa; + border: 1px solid #d8e0ea; + border-bottom: none; + padding: 7px 12px; + margin-right: 4px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +QTabBar::tab:selected { + background-color: #f8fbfe; + color: #1f2937; + font-weight: 600; +} +""" + + +def apply_theme(app: QApplication) -> None: + """Apply a light desktop theme aligned with the radar_system UI style.""" + app.setStyle(QStyleFactory.create("Fusion")) + + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor("#edf2f7")) + palette.setColor(QPalette.ColorRole.WindowText, QColor("#17212b")) + palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff")) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#f6f9fc")) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff")) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#17212b")) + palette.setColor(QPalette.ColorRole.Text, QColor("#17212b")) + palette.setColor(QPalette.ColorRole.Button, QColor("#f7fafc")) + palette.setColor(QPalette.ColorRole.ButtonText, QColor("#17212b")) + palette.setColor(QPalette.ColorRole.Highlight, QColor("#1f6feb")) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff")) + app.setPalette(palette) + app.setStyleSheet(_STYLESHEET) diff --git a/laser_control/gui/window.py b/laser_control/gui/window.py new file mode 100644 index 0000000..1d69639 --- /dev/null +++ b/laser_control/gui/window.py @@ -0,0 +1,752 @@ +"""Main PyQt window for the laser-controller desktop application.""" + +from __future__ import annotations + +from collections import deque +from datetime import datetime +import re + +from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal +from PyQt6.QtGui import QTextCursor +from PyQt6.QtWidgets import ( + QFileDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QMainWindow, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) +import pyqtgraph as pg + +from laser_control.constants import ( + AD9833_MCLK_HZ, + DEFAULT_AD9102_AMPLITUDE, + DEFAULT_AD9102_HOLD_CYCLES, + DEFAULT_AD9102_PAT_BASE, + DEFAULT_AD9102_PAT_PERIOD, + DEFAULT_AD9102_SAMPLE_COUNT, + DEFAULT_AD9102_SAW_FREQUENCY_HZ, + DEFAULT_AD9102_SRAM_FREQUENCY_HZ, + DEFAULT_PI_I, + DEFAULT_PI_P, + GUI_POLL_INTERVAL_MS, + PLOT_POINTS, +) +from laser_control.conversions import current_ma_to_n, temp_c_to_n +from laser_control.controller import ( + ad9102_saw_frequency_from_step_hz, + ad9102_saw_frequency_limits_hz, + ad9102_saw_step_from_frequency_hz, + ad9102_sram_frequency_from_playback_hz, + ad9102_sram_frequency_limits_hz, + ad9102_sram_sample_count_from_frequency_hz, +) +from laser_control.models import DeviceStatus, Measurements, ProfileSaveRequest + +from .dialogs import ProfileSaveDialog +from .sections import ( + build_device_group, + build_log_group, + build_manual_group, + build_status_group, +) +from .worker import ControllerWorker + + +class MainWindow(QMainWindow): + """Compact GUI composed around live plots and explicit control cards.""" + + request_connect = pyqtSignal() + request_apply_manual = pyqtSignal(float, float, float, float) + request_reset = pyqtSignal() + request_apply_ad9102 = pyqtSignal(dict) + request_apply_ad9833 = pyqtSignal(bool, bool, int) + request_pulse_ds1809 = pyqtSignal(bool, int, int) + request_set_stm32_dac = pyqtSignal(bool, int) + request_upload_wave = pyqtSignal(object) + request_cancel_wave = pyqtSignal() + request_save_profile = pyqtSignal(object) + request_poll = pyqtSignal() + request_shutdown = pyqtSignal() + + def __init__(self, *, auto_connect: bool = True) -> None: + super().__init__() + self.setWindowTitle("Управление лазерной схемой") + + self._connected = False + self._port_name = "" + self._poll_in_flight = False + self._command_in_flight = False + + self._temp1_history = deque(maxlen=PLOT_POINTS) + self._temp2_history = deque(maxlen=PLOT_POINTS) + self._current1_history = deque(maxlen=PLOT_POINTS) + self._current2_history = deque(maxlen=PLOT_POINTS) + + self._build_ui() + self._update_ad9102_form() + self._update_ad9833_preview() + self._on_wave_text_changed() + self._update_control_state() + self._build_worker() + + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(GUI_POLL_INTERVAL_MS) + self._poll_timer.timeout.connect(self._request_poll_if_idle) + self._poll_timer.start() + + self._append_log("INFO", "GUI started") + if auto_connect: + self._emit_connect_request() + + def _build_ui(self) -> None: + root = QWidget(self) + self.setCentralWidget(root) + + layout = QHBoxLayout(root) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(14) + + layout.addWidget(self._build_plot_panel(), stretch=11) + layout.addWidget(self._build_side_panel(), stretch=5) + + def _build_plot_panel(self) -> QWidget: + panel = QWidget(self) + grid = QGridLayout(panel) + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(12) + grid.setVerticalSpacing(12) + + self._plot_temp1, self._curve_temp1 = self._build_plot_card( + "Температура лазера 1", + "#ffb703", + 0, + 50, + ) + self._plot_temp2, self._curve_temp2 = self._build_plot_card( + "Температура лазера 2", + "#fb8500", + 0, + 50, + ) + self._plot_current1, self._curve_current1 = self._build_plot_card( + "Фотодиод 1", + "#219ebc", + 0, + 1.2, + ) + self._plot_current2, self._curve_current2 = self._build_plot_card( + "Фотодиод 2", + "#2a9d8f", + 0, + 1.2, + ) + + grid.addWidget(self._plot_temp1, 0, 0) + grid.addWidget(self._plot_temp2, 0, 1) + grid.addWidget(self._plot_current1, 1, 0) + grid.addWidget(self._plot_current2, 1, 1) + grid.setColumnStretch(0, 1) + grid.setColumnStretch(1, 1) + grid.setRowStretch(0, 1) + grid.setRowStretch(1, 1) + return panel + + def _build_plot_card( + self, + title: str, + color: str, + y_min: float, + y_max: float, + ) -> tuple[QWidget, pg.PlotDataItem]: + container = QWidget(self) + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(8) + + label = QLabel(title, container) + label.setObjectName("valueLabel") + container_layout.addWidget(label) + + plot = pg.PlotWidget(background="#0f1720", enableMenu=False) + plot.showGrid(x=True, y=True, alpha=0.16) + plot.setYRange(y_min, y_max) + plot.setXRange(0, max(1, PLOT_POINTS - 1)) + plot.setMouseEnabled(x=False, y=False) + plot.getPlotItem().hideButtons() + plot.getPlotItem().setClipToView(True) + plot.getPlotItem().setDownsampling(mode="peak") + plot.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + curve = plot.plot(pen=pg.mkPen(color=color, width=2)) + container_layout.addWidget(plot, stretch=1) + return container, curve + + def _build_side_panel(self) -> QWidget: + panel = QWidget(self) + panel.setMinimumWidth(420) + layout = QVBoxLayout(panel) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(12) + + self._subtitle = QLabel("Автоподключение при запуске без автоприменения параметров") + self._subtitle.setObjectName("captionLabel") + layout.addWidget(self._subtitle) + layout.addWidget(build_manual_group(self)) + layout.addWidget(build_device_group(self)) + layout.addWidget(build_status_group(self)) + layout.addWidget(build_log_group(self), stretch=1) + layout.addStretch(1) + + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setWidget(panel) + return scroll + + def _build_worker(self) -> None: + self._worker_thread = QThread(self) + self._worker = ControllerWorker() + self._worker.moveToThread(self._worker_thread) + + self.request_connect.connect(self._worker.connect_device) + self.request_apply_manual.connect(self._worker.apply_manual) + self.request_reset.connect(self._worker.reset_device) + self.request_apply_ad9102.connect(self._worker.apply_ad9102) + self.request_apply_ad9833.connect(self._worker.apply_ad9833) + self.request_pulse_ds1809.connect(self._worker.pulse_ds1809) + self.request_set_stm32_dac.connect(self._worker.set_stm32_dac) + self.request_upload_wave.connect(self._worker.upload_ad9102_waveform) + self.request_cancel_wave.connect(self._worker.cancel_ad9102_waveform_upload) + self.request_save_profile.connect(self._worker.save_profile) + self.request_poll.connect(self._worker.poll) + self.request_shutdown.connect(self._worker.shutdown) + + self._worker.connected_changed.connect(self._on_connected_changed) + self._worker.measurements_ready.connect(self._on_measurements_ready) + self._worker.status_ready.connect(self._on_status_ready) + self._worker.log_message.connect(self._append_log) + self._worker.command_finished.connect(self._on_command_finished) + self._worker.poll_finished.connect(self._on_poll_finished) + + self._worker_thread.start() + + def _emit_connect_request(self) -> None: + self._dispatch_command(self.request_connect.emit) + + def _dispatch_command(self, emit_request) -> None: + if self._command_in_flight: + return + + self._command_in_flight = True + self._poll_timer.stop() + self._update_control_state() + emit_request() + + def _request_poll_if_idle(self) -> None: + if not self._connected or self._command_in_flight or self._poll_in_flight: + return + + self._poll_in_flight = True + self.request_poll.emit() + + def _on_command_finished(self) -> None: + self._command_in_flight = False + self._update_control_state() + if self._connected and not self._poll_timer.isActive(): + self._poll_timer.start() + + def _on_poll_finished(self) -> None: + self._poll_in_flight = False + + def _on_apply_manual(self) -> None: + self._dispatch_command( + lambda: self.request_apply_manual.emit( + self._manual_temp1.value(), + self._manual_temp2.value(), + self._manual_current1.value(), + self._manual_current2.value(), + ) + ) + + def _on_reset_device(self) -> None: + self._dispatch_command(self.request_reset.emit) + + def _on_apply_ad9102(self) -> None: + use_sram = self._ad9102_mode.currentData() == "sram" + advanced = self._ad9102_advanced_toggle.isChecked() + config = { + "use_basic": not advanced, + "enabled": self._ad9102_enable.isChecked(), + "use_sram": use_sram, + "triangle": self._ad9102_shape.currentData() == "triangle", + "frequency_hz": self._ad9102_frequency_hz.value(), + "saw_step": self._ad9102_saw_step.value(), + "pat_period_base": self._ad9102_pat_base.value(), + "pat_period": self._ad9102_pat_period.value(), + "sample_count": self._ad9102_sample_count.value(), + "hold_cycles": self._ad9102_hold_cycles.value(), + "amplitude": self._ad9102_amplitude.value(), + "use_amplitude_format": use_sram and (not advanced or self._ad9102_use_amplitude.isChecked()), + } + self._dispatch_command(lambda: self.request_apply_ad9102.emit(config)) + + def _on_apply_ad9833(self) -> None: + self._dispatch_command( + lambda: self.request_apply_ad9833.emit( + self._ad9833_enable.isChecked(), + self._ad9833_shape.currentData() == "triangle", + self._ad9833_frequency_hz.value(), + ) + ) + + def _on_pulse_ds1809(self) -> None: + self._dispatch_command( + lambda: self.request_pulse_ds1809.emit( + self._ds1809_direction.currentData() == "inc", + self._ds1809_count.value(), + self._ds1809_pulse_ms.value(), + ) + ) + + def _on_apply_stm32_dac(self) -> None: + self._dispatch_command( + lambda: self.request_set_stm32_dac.emit( + self._stm32_dac_enable.isChecked(), + self._stm32_dac_code.value(), + ) + ) + + def _on_save_profile(self) -> None: + dialog = ProfileSaveDialog( + custom_waveform_available=self._custom_waveform_is_available(), + parent=self, + ) + if dialog.exec() != ProfileSaveDialog.DialogCode.Accepted: + return + + try: + request = self._build_profile_save_request( + profile_name=dialog.profile_name(), + include_custom_waveform=dialog.include_custom_waveform(), + ) + except Exception as exc: # noqa: BLE001 + self._append_log("ERROR", str(exc)) + return + + self._dispatch_command(lambda: self.request_save_profile.emit(request)) + + def _on_upload_waveform(self) -> None: + try: + samples = self._parse_wave_samples(self._wave_samples_box.toPlainText()) + if len(samples) < 2: + raise ValueError("Для загрузки waveform нужно минимум 2 отсчёта") + except Exception as exc: # noqa: BLE001 + self._append_log("ERROR", str(exc)) + return + self._dispatch_command(lambda: self.request_upload_wave.emit(samples)) + + def _on_cancel_waveform(self) -> None: + self._dispatch_command(self.request_cancel_wave.emit) + + def _on_load_wave_file(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + "Открыть файл waveform", + "", + "Text files (*.txt *.csv *.dat);;All files (*)", + ) + if not path: + return + try: + with open(path, encoding="utf-8") as handle: + self._wave_samples_box.setPlainText(handle.read()) + self._append_log("INFO", f"Waveform file loaded: {path}") + except Exception as exc: # noqa: BLE001 + self._append_log("ERROR", f"Не удалось открыть файл: {exc}") + + def _on_wave_text_changed(self) -> None: + text = self._wave_samples_box.toPlainText().strip() + if not text: + self._wave_info_label.setText("Отсчётов: 0") + return + try: + count = len(self._parse_wave_samples(text)) + self._wave_info_label.setText(f"Отсчётов: {count}") + except Exception: + self._wave_info_label.setText("Отсчётов: ошибка формата") + + def _on_reconnect(self) -> None: + self._append_log("INFO", "Reconnect requested from UI") + self._emit_connect_request() + + def _on_connected_changed(self, connected: bool, port_name: str) -> None: + self._connected = connected + self._port_name = port_name + if not connected: + self._poll_in_flight = False + self._command_in_flight = False + if connected: + self._status_header.setText("Подключено") + self._status_header.setObjectName("statusOk") + self._subtitle.setText(f"Подключено к {port_name}") + else: + self._status_header.setText("Отключено") + self._status_header.setObjectName("statusError") + self._subtitle.setText("Автоподключение при запуске без автоприменения параметров") + self._status_header.style().unpolish(self._status_header) + self._status_header.style().polish(self._status_header) + self._update_control_state() + + def _update_control_state(self) -> None: + connected = self._connected and not self._command_in_flight + + self._apply_manual_button.setEnabled(connected) + self._apply_ad9102_button.setEnabled(connected) + self._apply_ad9833_button.setEnabled(connected) + self._apply_stm32_dac_button.setEnabled(connected) + self._pulse_ds1809_button.setEnabled(connected) + self._upload_wave_button.setEnabled(connected) + self._cancel_wave_button.setEnabled(connected) + self._save_profile_button.setEnabled(connected) + self._reset_button.setEnabled(connected) + self._reconnect_button.setEnabled(not self._command_in_flight) + self._load_wave_file_button.setEnabled(True) + + def _on_ad9102_mode_changed(self) -> None: + if not hasattr(self, "_ad9102_frequency_hz"): + return + + use_sram = self._ad9102_mode.currentData() == "sram" + if use_sram: + min_hz, max_hz = ad9102_sram_frequency_limits_hz() + frequency_hz = DEFAULT_AD9102_SRAM_FREQUENCY_HZ + else: + min_hz, max_hz = ad9102_saw_frequency_limits_hz( + triangle=self._ad9102_shape.currentData() == "triangle", + ) + frequency_hz = DEFAULT_AD9102_SAW_FREQUENCY_HZ + + frequency_hz = max(min_hz, min(max_hz, frequency_hz)) + self._ad9102_frequency_hz.blockSignals(True) + self._ad9102_frequency_hz.setRange(min_hz, max_hz) + self._ad9102_frequency_hz.setValue(frequency_hz) + self._ad9102_frequency_hz.blockSignals(False) + + if use_sram: + self._ad9102_sample_count.blockSignals(True) + self._ad9102_hold_cycles.blockSignals(True) + self._ad9102_amplitude.blockSignals(True) + self._ad9102_use_amplitude.blockSignals(True) + self._ad9102_sample_count.setValue(DEFAULT_AD9102_SAMPLE_COUNT) + self._ad9102_hold_cycles.setValue(DEFAULT_AD9102_HOLD_CYCLES) + self._ad9102_amplitude.setValue(DEFAULT_AD9102_AMPLITUDE) + self._ad9102_use_amplitude.setChecked(True) + self._ad9102_sample_count.blockSignals(False) + self._ad9102_hold_cycles.blockSignals(False) + self._ad9102_amplitude.blockSignals(False) + self._ad9102_use_amplitude.blockSignals(False) + + self._update_ad9102_form() + + def _update_ad9102_form(self) -> None: + if not hasattr(self, "_ad9102_advanced_group"): + return + + use_sram = self._ad9102_mode.currentData() == "sram" + advanced = self._ad9102_advanced_toggle.isChecked() + triangle = self._ad9102_shape.currentData() == "triangle" + use_amplitude = use_sram and (not advanced or self._ad9102_use_amplitude.isChecked()) + + self._ad9102_advanced_group.setVisible(advanced) + self._ad9102_frequency_hz.setEnabled(not advanced) + + self._ad9102_saw_step.setEnabled(advanced and not use_sram) + self._ad9102_pat_base.setEnabled(advanced and not use_sram) + self._ad9102_pat_period.setEnabled(advanced and not use_sram) + + self._ad9102_sample_count.setEnabled(advanced and use_sram) + self._ad9102_use_amplitude.setEnabled(advanced and use_sram) + self._ad9102_hold_cycles.setEnabled(advanced and use_sram and not self._ad9102_use_amplitude.isChecked()) + self._ad9102_amplitude.setEnabled(use_sram and use_amplitude) + + if advanced: + if use_sram: + sample_count = self._ad9102_sample_count.value() + hold_cycles = ( + 1 + if self._ad9102_use_amplitude.isChecked() + else (self._ad9102_hold_cycles.value() or 1) + ) + actual_frequency = ad9102_sram_frequency_from_playback_hz( + sample_count=sample_count, + hold_cycles=hold_cycles, + ) + self._ad9102_preview.setText( + "Реальная частота: " + f"{self._format_hz(actual_frequency)} " + f"(точек: {sample_count}, удержание: {hold_cycles})" + ) + if self._ad9102_use_amplitude.isChecked(): + hint = ( + "Расширенный режим памяти. Сейчас задаются размах и число точек. " + "Удержание фиксировано значением из прошивки: 1 такт на точку." + ) + else: + hint = ( + "Расширенный режим памяти. Сейчас задаются число точек и удержание. " + "Размах при этом возьмётся из прошивочного значения по умолчанию." + ) + else: + actual_frequency = ad9102_saw_frequency_from_step_hz( + triangle=triangle, + saw_step=self._ad9102_saw_step.value(), + ) + self._ad9102_preview.setText( + "Реальная частота: " + f"{self._format_hz(actual_frequency)} " + f"(код шага: {self._ad9102_saw_step.value()})" + ) + hint = ( + "Расширенный встроенный режим AD9102. Частота определяется в основном кодом шага, " + ) + else: + if use_sram: + min_hz, max_hz = ad9102_sram_frequency_limits_hz() + else: + min_hz, max_hz = ad9102_saw_frequency_limits_hz(triangle=triangle) + self._ad9102_frequency_hz.blockSignals(True) + self._ad9102_frequency_hz.setRange(min_hz, max_hz) + if self._ad9102_frequency_hz.value() < min_hz: + self._ad9102_frequency_hz.setValue(min_hz) + elif self._ad9102_frequency_hz.value() > max_hz: + self._ad9102_frequency_hz.setValue(max_hz) + self._ad9102_frequency_hz.blockSignals(False) + + desired_frequency = self._ad9102_frequency_hz.value() + if use_sram: + sample_count, actual_frequency = ad9102_sram_sample_count_from_frequency_hz( + frequency_hz=desired_frequency, + ) + self._ad9102_sample_count.blockSignals(True) + self._ad9102_hold_cycles.blockSignals(True) + self._ad9102_use_amplitude.blockSignals(True) + self._ad9102_sample_count.setValue(sample_count) + self._ad9102_hold_cycles.setValue(1) + self._ad9102_use_amplitude.setChecked(True) + self._ad9102_sample_count.blockSignals(False) + self._ad9102_hold_cycles.blockSignals(False) + self._ad9102_use_amplitude.blockSignals(False) + self._ad9102_preview.setText( + "Реальная частота: " + f"{self._format_hz(actual_frequency)} " + f"(точек: {sample_count}, удержание: 1)" + ) + hint = ( + f"Доступный диапазон: {min_hz:,}..{max_hz:,} Гц. " + ).replace(",", " ") + else: + saw_step, actual_frequency = ad9102_saw_step_from_frequency_hz( + triangle=triangle, + frequency_hz=desired_frequency, + ) + self._ad9102_saw_step.blockSignals(True) + self._ad9102_pat_base.blockSignals(True) + self._ad9102_pat_period.blockSignals(True) + self._ad9102_saw_step.setValue(saw_step) + self._ad9102_pat_base.setValue(DEFAULT_AD9102_PAT_BASE) + self._ad9102_pat_period.setValue(DEFAULT_AD9102_PAT_PERIOD) + self._ad9102_saw_step.blockSignals(False) + self._ad9102_pat_base.blockSignals(False) + self._ad9102_pat_period.blockSignals(False) + self._ad9102_preview.setText( + "Реальная частота: " + f"{self._format_hz(actual_frequency)} " + f"(код шага: {saw_step})" + ) + hint = ( + f"Доступный диапазон: {min_hz:,}..{max_hz:,} Гц. " + "Амплитуда этой STM-командой не управляется." + ).replace(",", " ") + + self._ad9102_basic_hint.setText(hint) + + @staticmethod + def _format_hz(value: float) -> str: + return f"{value:,.1f} Гц".replace(",", " ") + + def _update_ad9833_preview(self) -> None: + frequency_hz = self._ad9833_frequency_hz.value() + frequency_word = int(round(frequency_hz * (1 << 28) / AD9833_MCLK_HZ)) + self._ad9833_word_preview.setText( + f"Внутренний код: {frequency_word:,}".replace(",", " ") + ) + + def _on_measurements_ready(self, measurements: Measurements) -> None: + self._telemetry_temp1.setText(f"{measurements.temp1:.2f} °C") + self._telemetry_temp2.setText(f"{measurements.temp2:.2f} °C") + self._telemetry_current1.setText(f"{measurements.current1:.3f} мА") + self._telemetry_current2.setText(f"{measurements.current2:.3f} мА") + self._telemetry_temp_ext1.setText(f"{measurements.temp_ext1:.2f} °C") + self._telemetry_temp_ext2.setText(f"{measurements.temp_ext2:.2f} °C") + self._telemetry_3v3.setText(f"{measurements.voltage_3v3:.3f} В") + self._telemetry_5v1.setText(f"{measurements.voltage_5v1:.3f} В") + self._telemetry_5v2.setText(f"{measurements.voltage_5v2:.3f} В") + self._telemetry_7v0.setText(f"{measurements.voltage_7v0:.3f} В") + self._message_id_value.setText(str(measurements.message_id)) + + self._temp1_history.append(measurements.temp1) + self._temp2_history.append(measurements.temp2) + self._current1_history.append(measurements.current1) + self._current2_history.append(measurements.current2) + self._refresh_plot_curves() + + def _on_status_ready(self, status: DeviceStatus) -> None: + self._port_value.setText(self._port_name or "auto") + self._state_value.setText(status.error_message or "All ok.") + self._detail_value.setText(f"0x{status.detail:02X}") + self._message_id_value.setText( + str(status.last_command_id) if status.last_command_id is not None else "—" + ) + + if status.has_error: + self._status_header.setText("Есть ошибки") + self._status_header.setObjectName("statusError") + elif self._connected: + self._status_header.setText("Подключено") + self._status_header.setObjectName("statusOk") + + self._status_header.style().unpolish(self._status_header) + self._status_header.style().polish(self._status_header) + + def _refresh_plot_curves(self) -> None: + x1 = list(range(len(self._temp1_history))) + x2 = list(range(len(self._temp2_history))) + x3 = list(range(len(self._current1_history))) + x4 = list(range(len(self._current2_history))) + self._curve_temp1.setData(x1, list(self._temp1_history)) + self._curve_temp2.setData(x2, list(self._temp2_history)) + self._curve_current1.setData(x3, list(self._current1_history)) + self._curve_current2.setData(x4, list(self._current2_history)) + + def _append_log(self, level: str, message: str) -> None: + timestamp = datetime.now().strftime("%H:%M:%S") + self._log_box.append(f"[{timestamp}] {level:<5} {message}") + self._log_box.moveCursor(QTextCursor.MoveOperation.End) + + def _custom_waveform_is_available(self) -> bool: + try: + return len(self._parse_wave_samples(self._wave_samples_box.toPlainText())) >= 2 + except Exception: + return False + + def _build_profile_save_request( + self, + *, + profile_name: str, + include_custom_waveform: bool, + ) -> ProfileSaveRequest: + custom_wave_samples: list[int] = [] + if include_custom_waveform: + custom_wave_samples = self._parse_wave_samples(self._wave_samples_box.toPlainText()) + if len(custom_wave_samples) < 2: + raise ValueError("Для сохранения пользовательской формы нужно минимум 2 отсчёта") + + return ProfileSaveRequest( + profile_name=profile_name.strip(), + profile_text=self._build_profile_text( + profile_name=profile_name.strip(), + custom_wave_samples=custom_wave_samples, + ), + waveform_text=self._build_waveform_text(custom_wave_samples) if custom_wave_samples else "", + ) + + def _build_profile_text(self, *, profile_name: str, custom_wave_samples: list[int]) -> str: + waveform_mode = "custom_sram" if custom_wave_samples else ( + "generated_sram" if self._ad9102_mode.currentData() == "sram" else "saw" + ) + waveform_sample_count = len(custom_wave_samples) if custom_wave_samples else self._ad9102_sample_count.value() + waveform_hold_cycles = 1 if custom_wave_samples else self._ad9102_hold_cycles.value() + waveform_triangle = 1 if self._ad9102_shape.currentData() == "triangle" else 0 + ad9833_frequency_word = int(round(self._ad9833_frequency_hz.value() * (1 << 28) / AD9833_MCLK_HZ)) + pid_p = DEFAULT_PI_P / 256.0 + pid_i = DEFAULT_PI_I / 256.0 + + lines = [ + "# Saved from the desktop GUI.", + f"profile_name={profile_name}", + "boot_enabled=true", + "auto_run=true", + "", + "work_enable=1", + "u5v1_enable=1", + "u5v2_enable=1", + "ld1_enable=1", + "ld2_enable=1", + "ref1_enable=1", + "ref2_enable=1", + "tec1_enable=1", + "tec2_enable=1", + "ts1_enable=1", + "ts2_enable=1", + "", + "pid1_from_host=1", + "pid2_from_host=1", + "averages=0", + "message_id=0", + "", + f"laser1_target_temp={temp_c_to_n(self._manual_temp1.value())}", + f"laser2_target_temp={temp_c_to_n(self._manual_temp2.value())}", + f"laser1_current={current_ma_to_n(self._manual_current1.value())}", + f"laser2_current={current_ma_to_n(self._manual_current2.value())}", + f"laser1_pid_p={pid_p:.6g}", + f"laser1_pid_i={pid_i:.6g}", + f"laser2_pid_p={pid_p:.6g}", + f"laser2_pid_i={pid_i:.6g}", + "", + f"waveform_mode={waveform_mode}", + f"waveform_enable={1 if self._ad9102_enable.isChecked() else 0}", + f"waveform_triangle={waveform_triangle}", + f"waveform_saw_step={self._ad9102_saw_step.value()}", + f"waveform_pat_base={self._ad9102_pat_base.value()}", + f"waveform_pat_period={self._ad9102_pat_period.value()}", + f"waveform_sample_count={waveform_sample_count}", + f"waveform_hold_cycles={waveform_hold_cycles}", + f"waveform_amplitude={self._ad9102_amplitude.value()}", + "", + f"ad9833_enable={1 if self._ad9833_enable.isChecked() else 0}", + f"ad9833_triangle={1 if self._ad9833_shape.currentData() == 'triangle' else 0}", + f"ad9833_frequency_word={ad9833_frequency_word}", + "", + f"stm32_dac_enable={1 if self._stm32_dac_enable.isChecked() else 0}", + f"stm32_dac_code={self._stm32_dac_code.value()}", + "", + f"ds1809_apply={'true' if self._ds1809_profile_apply.isChecked() else 'false'}", + f"ds1809_position_from_min={self._ds1809_profile_position.value()}", + ] + return "\n".join(lines) + "\n" + + @staticmethod + def _build_waveform_text(samples: list[int]) -> str: + return "\n".join(str(sample) for sample in samples) + "\n" + + @staticmethod + def _parse_wave_samples(text: str) -> list[int]: + cleaned = ( + text.replace("[", " ") + .replace("]", " ") + .replace("(", " ") + .replace(")", " ") + ) + tokens = [token for token in re.split(r"[\s,;]+", cleaned.strip()) if token] + return [int(token, 0) for token in tokens] + + def closeEvent(self, event) -> None: # noqa: N802 + self._poll_timer.stop() + self.request_shutdown.emit() + self._worker_thread.quit() + self._worker_thread.wait(3000) + super().closeEvent(event) diff --git a/laser_control/gui/worker.py b/laser_control/gui/worker.py new file mode 100644 index 0000000..4197d9a --- /dev/null +++ b/laser_control/gui/worker.py @@ -0,0 +1,290 @@ +"""Worker object hosting the controller in a dedicated QThread.""" + +from __future__ import annotations + +from collections.abc import Callable +import time + +from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot + +from laser_control import ( + CommunicationError, + DeviceNotRespondingError, + LaserController, +) + + +class ControllerWorker(QObject): + """Run blocking serial I/O away from the GUI thread.""" + + connected_changed = pyqtSignal(bool, str) + measurements_ready = pyqtSignal(object) + status_ready = pyqtSignal(object) + log_message = pyqtSignal(str, str) + command_finished = pyqtSignal() + poll_finished = pyqtSignal() + + def __init__(self) -> None: + super().__init__() + self._controller = LaserController() + self._poll_in_progress = False + self._last_status_time = 0.0 + + @pyqtSlot() + def connect_device(self) -> None: + """Connect to the board and query current status without changing setpoints.""" + self._run_command(self._connect_device_impl) + + @pyqtSlot(float, float, float, float) + def apply_manual( + self, + temp1: float, + temp2: float, + current1: float, + current2: float, + ) -> None: + """Apply manual setpoints on the device.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._apply_manual_impl(temp1, temp2, current1, current2), + ) + ) + + @pyqtSlot() + def reset_device(self) -> None: + """Send the firmware default command.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._reset_device_impl(), + ) + ) + + @pyqtSlot(dict) + def apply_ad9102(self, config: dict) -> None: + """Configure AD9102 generator state.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._apply_ad9102_impl(config), + ) + ) + + @pyqtSlot(bool, bool, int) + def apply_ad9833(self, enabled: bool, triangle: bool, frequency_hz: int) -> None: + """Configure AD9833 generator state.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._apply_ad9833_impl(enabled, triangle, frequency_hz), + ) + ) + + @pyqtSlot(bool, int, int) + def pulse_ds1809(self, increment: bool, count: int, pulse_ms: int) -> None: + """Pulse the DS1809 potentiometer.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._pulse_ds1809_impl(increment, count, pulse_ms), + ) + ) + + @pyqtSlot(bool, int) + def set_stm32_dac(self, enabled: bool, dac_code: int) -> None: + """Configure the STM32 DAC.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._set_stm32_dac_impl(enabled, dac_code), + ) + ) + + @pyqtSlot(object) + def save_profile(self, request: object) -> None: + """Save the current GUI configuration to the device SD card.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._save_profile_impl(request), + ) + ) + + @pyqtSlot(object) + def upload_ad9102_waveform(self, samples: object) -> None: + """Upload a custom waveform to AD9102 SRAM.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._upload_ad9102_waveform_impl(samples), + ) + ) + + @pyqtSlot() + def cancel_ad9102_waveform_upload(self) -> None: + """Cancel an in-progress waveform upload.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._cancel_ad9102_waveform_upload_impl(), + ) + ) + + @pyqtSlot() + def poll(self) -> None: + """Fetch measurements regularly and refresh status once per second.""" + if self._poll_in_progress or not self._controller.is_connected: + return + + self._poll_in_progress = True + try: + measurements = self._controller.get_measurements() + if measurements is not None: + self.measurements_ready.emit(measurements) + + now = time.monotonic() + if now - self._last_status_time >= 1.0: + self._emit_status() + except (CommunicationError, DeviceNotRespondingError) as exc: + self.log_message.emit("ERROR", str(exc)) + self._disconnect_silently() + except Exception as exc: # noqa: BLE001 + self.log_message.emit("ERROR", str(exc)) + finally: + self._poll_in_progress = False + self.poll_finished.emit() + + @pyqtSlot() + def shutdown(self) -> None: + """Disconnect gracefully when the GUI closes.""" + self._disconnect_silently() + + def _run_command(self, action: Callable[[], None]) -> None: + try: + action() + except Exception as exc: # noqa: BLE001 + self.log_message.emit("ERROR", str(exc)) + finally: + self.command_finished.emit() + + def _connect_device_impl(self) -> None: + self._disconnect_silently() + try: + self._controller.connect() + self.connected_changed.emit(True, self._controller.port_name or "") + self.log_message.emit( + "INFO", + f"Connected to {self._controller.port_name or 'auto-detected port'}", + ) + self._emit_status() + measurements = self._controller.get_measurements() + if measurements is not None: + self.measurements_ready.emit(measurements) + except Exception: + self._disconnect_silently() + raise + + def _apply_manual_impl(self, temp1: float, temp2: float, current1: float, current2: float) -> None: + self._controller.set_manual_mode(temp1, temp2, current1, current2) + self.log_message.emit( + "INFO", + f"Manual mode applied: T1={temp1:.2f} T2={temp2:.2f} I1={current1:.3f} I2={current2:.3f}", + ) + self._emit_status() + + def _reset_device_impl(self) -> None: + self._controller.reset() + self.log_message.emit("INFO", "DEFAULT_ENABLE sent") + self._emit_status() + + def _apply_ad9102_impl(self, config: dict) -> None: + if config.pop("use_basic", False): + simple_config = { + "enabled": config["enabled"], + "use_sram": config["use_sram"], + "triangle": config["triangle"], + "frequency_hz": config["frequency_hz"], + "amplitude": config["amplitude"], + } + result = self._controller.configure_ad9102_simple(**simple_config) + actual_frequency_hz = float(result["actual_frequency_hz"]) + if simple_config["use_sram"]: + self.log_message.emit( + "INFO", + "AD9102 memory waveform applied: " + f"{actual_frequency_hz:.1f} Hz, " + f"samples={result['sample_count']}, " + f"amplitude={simple_config['amplitude']}", + ) + else: + self.log_message.emit( + "INFO", + "AD9102 built-in waveform applied: " + f"{actual_frequency_hz:.1f} Hz, " + f"saw_step={result['saw_step']}", + ) + else: + self._controller.configure_ad9102(**config) + self.log_message.emit("INFO", "AD9102 advanced settings applied") + self._emit_status() + + def _apply_ad9833_impl(self, enabled: bool, triangle: bool, frequency_hz: int) -> None: + frequency_word = self._controller.configure_ad9833_frequency( + enabled=enabled, + triangle=triangle, + frequency_hz=frequency_hz, + ) + self.log_message.emit( + "INFO", + f"AD9833 settings applied: {frequency_hz} Hz, code={frequency_word}", + ) + self._emit_status() + + def _pulse_ds1809_impl(self, increment: bool, count: int, pulse_ms: int) -> None: + self._controller.pulse_ds1809( + increment=increment, + count=count, + pulse_ms=pulse_ms, + ) + direction = "increment" if increment else "decrement" + self.log_message.emit("INFO", f"DS1809 pulse: {direction}, count={count}, pulse={pulse_ms} ms") + self._emit_status() + + def _set_stm32_dac_impl(self, enabled: bool, dac_code: int) -> None: + self._controller.set_stm32_dac(enabled=enabled, dac_code=dac_code) + self.log_message.emit("INFO", f"STM32 DAC set to code {dac_code}") + self._emit_status() + + def _save_profile_impl(self, request: object) -> None: + self._controller.save_profile_to_sd(request) + profile_name = getattr(request, "profile_name", "") + self.log_message.emit("INFO", f"Profile saved to SD: {profile_name}") + self._emit_status() + + def _upload_ad9102_waveform_impl(self, samples: object) -> None: + sample_list = list(samples) + self._controller.upload_ad9102_waveform(sample_list) + self.log_message.emit("INFO", f"AD9102 waveform uploaded ({len(sample_list)} samples)") + self._emit_status() + + def _cancel_ad9102_waveform_upload_impl(self) -> None: + self._controller.cancel_ad9102_waveform_upload() + self.log_message.emit("INFO", "AD9102 waveform upload cancelled") + self._emit_status() + + def _emit_status(self) -> None: + status = self._controller.get_status() + self._last_status_time = time.monotonic() + self.status_ready.emit(status) + + def _ensure_connected(self) -> None: + if not self._controller.is_connected: + raise CommunicationError("Device is not connected") + + def _disconnect_silently(self) -> None: + try: + if self._controller.is_connected: + self._controller.disconnect() + finally: + self.connected_changed.emit(False, "") diff --git a/laser_control/models.py b/laser_control/models.py index df547d5..3f3d311 100644 --- a/laser_control/models.py +++ b/laser_control/models.py @@ -1,219 +1,128 @@ -""" -Data models for laser control module. +"""Public domain models used by the controller and GUI layers.""" -Provides dataclasses and enums for structured data representation -throughout the laser control system. -""" - -from dataclasses import dataclass -from enum import IntEnum -from typing import Optional, Dict, Any +from dataclasses import dataclass, field from datetime import datetime +from enum import IntFlag +from typing import Any + +from .constants import ( + VOLT_3V3_MAX, + VOLT_3V3_MIN, + VOLT_5V_MAX, + VOLT_5V_MIN, + VOLT_7V_MAX, + VOLT_7V_MIN, +) + +class DeviceState(IntFlag): + """Bit-mask of device error flags returned by the firmware status packet.""" + + OK = 0x0000 + SD_ERROR = 0x0001 + UART_ERROR = 0x0002 + UART_DECODE_ERROR = 0x0004 + TEC1_ERROR = 0x0008 + TEC2_ERROR = 0x0010 + DEFAULT_ERROR = 0x0020 + AD9102_ERROR = 0x0080 -class VariationType(IntEnum): - """Types of parameter variation modes.""" - MANUAL = 0x00 - CHANGE_CURRENT_LD1 = 0x01 - CHANGE_CURRENT_LD2 = 0x02 - CHANGE_TEMPERATURE_LD1 = 0x03 - CHANGE_TEMPERATURE_LD2 = 0x04 - - -class DeviceState(IntEnum): - """Device operational states.""" - IDLE = 0x0000 - RUNNING = 0x0001 - BUSY = 0x0002 - ERROR = 0x00FF - ERROR_OVERHEAT = 0x0100 - ERROR_POWER = 0x0200 - ERROR_COMMUNICATION = 0x0400 - ERROR_INVALID_COMMAND = 0x0800 - - -@dataclass -class ManualModeParams: - """Parameters for manual control mode.""" - temp1: float # Temperature for laser 1 (°C) - temp2: float # Temperature for laser 2 (°C) - current1: float # Current for laser 1 (mA) - current2: float # Current for laser 2 (mA) - pi_coeff1_p: float = 1.0 # PI controller proportional coefficient for laser 1 - pi_coeff1_i: float = 0.5 # PI controller integral coefficient for laser 1 - pi_coeff2_p: float = 1.0 # PI controller proportional coefficient for laser 2 - pi_coeff2_i: float = 0.5 # PI controller integral coefficient for laser 2 - - def to_dict(self) -> Dict[str, float]: - """Convert to dictionary representation.""" - return { - 'temp1': self.temp1, - 'temp2': self.temp2, - 'current1': self.current1, - 'current2': self.current2, - 'pi_coeff1_p': self.pi_coeff1_p, - 'pi_coeff1_i': self.pi_coeff1_i, - 'pi_coeff2_p': self.pi_coeff2_p, - 'pi_coeff2_i': self.pi_coeff2_i - } - - -@dataclass -class VariationParams: - """Parameters for variation mode.""" - variation_type: VariationType - # Static parameters (fixed during variation) - static_temp1: float - static_temp2: float - static_current1: float - static_current2: float - # Variation range - min_value: float # Minimum value for varied parameter - max_value: float # Maximum value for varied parameter - step: float # Step size for variation - # Time parameters - time_step: int # Time step in microseconds (20-100) - delay_time: int # Delay between measurements in milliseconds (3-10) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary representation.""" - return { - 'variation_type': self.variation_type.value, - 'static_temp1': self.static_temp1, - 'static_temp2': self.static_temp2, - 'static_current1': self.static_current1, - 'static_current2': self.static_current2, - 'min_value': self.min_value, - 'max_value': self.max_value, - 'step': self.step, - 'time_step': self.time_step, - 'delay_time': self.delay_time - } - - -@dataclass +@dataclass(slots=True) class Measurements: - """Real-time measurements from the device.""" - # Photodiode currents - current1: float # Photodiode current for laser 1 (mA) - current2: float # Photodiode current for laser 2 (mA) - # Temperatures - temp1: float # Temperature of laser 1 (°C) - temp2: float # Temperature of laser 2 (°C) - temp_ext1: Optional[float] = None # External thermistor 1 temperature (°C) - temp_ext2: Optional[float] = None # External thermistor 2 temperature (°C) - # Power supply voltages - voltage_3v3: float = 0.0 # 3.3V rail voltage - voltage_5v1: float = 0.0 # 5V rail 1 voltage - voltage_5v2: float = 0.0 # 5V rail 2 voltage - voltage_7v0: float = 0.0 # 7V rail voltage - # Metadata - timestamp: Optional[datetime] = None - message_id: Optional[int] = None - to6_counter_lsb: Optional[int] = None - to6_counter_msb: Optional[int] = None + """Latest live telemetry frame decoded from the board.""" - def __post_init__(self): - """Set timestamp if not provided.""" - if self.timestamp is None: - self.timestamp = datetime.now() + current1: float + current2: float + temp1: float + temp2: float + temp_ext1: float | None = None + temp_ext2: float | None = None + voltage_3v3: float = 0.0 + voltage_5v1: float = 0.0 + voltage_5v2: float = 0.0 + voltage_7v0: float = 0.0 + message_id: int | None = None + to6_counter_lsb: int | None = None + to6_counter_msb: int | None = None + timestamp: datetime = field(default_factory=datetime.now) - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary representation.""" + def to_dict(self) -> dict[str, Any]: + """Return a JSON-friendly representation.""" return { - 'current1': self.current1, - 'current2': self.current2, - 'temp1': self.temp1, - 'temp2': self.temp2, - 'temp_ext1': self.temp_ext1, - 'temp_ext2': self.temp_ext2, - 'voltage_3v3': self.voltage_3v3, - 'voltage_5v1': self.voltage_5v1, - 'voltage_5v2': self.voltage_5v2, - 'voltage_7v0': self.voltage_7v0, - 'timestamp': self.timestamp.isoformat() if self.timestamp else None, - 'message_id': self.message_id + "current1": self.current1, + "current2": self.current2, + "temp1": self.temp1, + "temp2": self.temp2, + "temp_ext1": self.temp_ext1, + "temp_ext2": self.temp_ext2, + "voltage_3v3": self.voltage_3v3, + "voltage_5v1": self.voltage_5v1, + "voltage_5v2": self.voltage_5v2, + "voltage_7v0": self.voltage_7v0, + "message_id": self.message_id, + "to6_counter_lsb": self.to6_counter_lsb, + "to6_counter_msb": self.to6_counter_msb, + "timestamp": self.timestamp.isoformat(), } - def check_power_rails(self) -> Dict[str, bool]: - """Check if power supply voltages are within acceptable range.""" + def check_power_rails(self) -> dict[str, bool]: + """Check nominal supply rails against static tolerances.""" return { - '3v3': 3.1 <= self.voltage_3v3 <= 3.5, - '5v1': 4.8 <= self.voltage_5v1 <= 5.3, - '5v2': 4.8 <= self.voltage_5v2 <= 5.3, - '7v0': 6.5 <= self.voltage_7v0 <= 7.5 + "3v3": VOLT_3V3_MIN <= self.voltage_3v3 <= VOLT_3V3_MAX, + "5v1": VOLT_5V_MIN <= self.voltage_5v1 <= VOLT_5V_MAX, + "5v2": VOLT_5V_MIN <= self.voltage_5v2 <= VOLT_5V_MAX, + "7v0": VOLT_7V_MIN <= self.voltage_7v0 <= VOLT_7V_MAX, } -@dataclass +@dataclass(slots=True) class DeviceStatus: - """Complete device status information.""" - state: DeviceState - measurements: Optional[Measurements] = None + """Decoded two-byte status response from the board.""" + + state: DeviceState = DeviceState.OK + detail: int = 0 + measurements: Measurements | None = None is_connected: bool = False - last_command_id: Optional[int] = None - error_message: Optional[str] = None - - @property - def is_idle(self) -> bool: - """Check if device is idle.""" - return self.state == DeviceState.IDLE - - @property - def is_running(self) -> bool: - """Check if device is running a task.""" - return self.state == DeviceState.RUNNING + last_command_id: int | None = None + error_message: str | None = None @property def has_error(self) -> bool: - """Check if device has any error.""" - return self.state >= DeviceState.ERROR + """Return True when any firmware error bit is set.""" + return self.state != DeviceState.OK @property - def error_type(self) -> Optional[str]: - """Get human-readable error type.""" - if not self.has_error: - return None + def is_ok(self) -> bool: + """Convenience alias for the common no-error case.""" + return not self.has_error - error_map = { - DeviceState.ERROR_OVERHEAT: "Overheating", - DeviceState.ERROR_POWER: "Power supply issue", - DeviceState.ERROR_COMMUNICATION: "Communication error", - DeviceState.ERROR_INVALID_COMMAND: "Invalid command" - } - return error_map.get(self.state, "Unknown error") + @property + def active_errors(self) -> list[str]: + """Return the names of all active error flags.""" + return [ + flag.name + for flag in DeviceState + if flag is not DeviceState.OK and (self.state & flag) == flag + ] - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary representation.""" + def to_dict(self) -> dict[str, Any]: + """Return a JSON-friendly representation.""" return { - 'state': self.state.value, - 'state_name': self.state.name, - 'measurements': self.measurements.to_dict() if self.measurements else None, - 'is_connected': self.is_connected, - 'last_command_id': self.last_command_id, - 'error_message': self.error_message, - 'is_idle': self.is_idle, - 'is_running': self.is_running, - 'has_error': self.has_error, - 'error_type': self.error_type + "state_mask": int(self.state), + "state_names": self.active_errors, + "detail": self.detail, + "measurements": self.measurements.to_dict() if self.measurements else None, + "is_connected": self.is_connected, + "last_command_id": self.last_command_id, + "error_message": self.error_message, + "has_error": self.has_error, } -@dataclass -class CalibrationData: - """Calibration data for device sensors.""" - # Temperature calibration coefficients - temp1_offset: float = 0.0 - temp1_scale: float = 1.0 - temp2_offset: float = 0.0 - temp2_scale: float = 1.0 - # Current calibration coefficients - current1_offset: float = 0.0 - current1_scale: float = 1.0 - current2_offset: float = 0.0 - current2_scale: float = 1.0 - # Voltage calibration - voltage_3v3_scale: float = 1.0 - voltage_5v1_scale: float = 1.0 - voltage_5v2_scale: float = 1.0 - voltage_7v0_scale: float = 1.0 \ No newline at end of file +@dataclass(slots=True) +class ProfileSaveRequest: + """Rendered profile payload that should be persisted on the device SD card.""" + + profile_name: str + profile_text: str + waveform_text: str = "" diff --git a/laser_control/protocol.py b/laser_control/protocol.py index 368d5d8..3e0a204 100644 --- a/laser_control/protocol.py +++ b/laser_control/protocol.py @@ -1,260 +1,151 @@ -""" -Communication protocol for laser control module. +"""Codec for the UART protocol implemented by the current firmware.""" -Encodes commands to bytes and decodes device responses. -Faithful re-implementation of the logic in device_commands.py, -refactored into a clean, testable class-based API. -""" +from __future__ import annotations -import struct -from typing import Optional -from enum import IntEnum from datetime import datetime - -import serial -import serial.tools.list_ports +import struct from .constants import ( - BAUDRATE, SERIAL_TIMEOUT_SEC, + AD9102_FLAG_ENABLE, + AD9102_FLAG_SRAM, + AD9102_FLAG_SRAM_FORMAT_ALT, + AD9102_FLAG_TRIANGLE, + AD9102_WAVE_MAX_CHUNK_SAMPLES, + AD9102_WAVE_OPCODE_BEGIN, + AD9102_WAVE_OPCODE_CANCEL, + AD9102_WAVE_OPCODE_COMMIT, + AD9102_WAVE_SAMPLE_MAX, + AD9102_WAVE_SAMPLE_MIN, + AD9833_FLAG_ENABLE, + AD9833_FLAG_TRIANGLE, + CMD_DECODE_ENABLE, + CMD_DEFAULT_ENABLE, + CMD_PROFILE_SAVE_CONTROL, + CMD_PROFILE_SAVE_DATA, + CMD_AD9102_CONTROL, + CMD_AD9102_WAVE_CONTROL, + CMD_AD9102_WAVE_DATA, + CMD_AD9833_CONTROL, + CMD_DS1809_CONTROL, + CMD_STATE, + CMD_STM32_DAC_CONTROL, + CMD_TRANS_ENABLE, + DEFAULT_SETUP_WORD, + DS1809_FLAG_DECREMENT, + DS1809_FLAG_INCREMENT, GET_DATA_TOTAL_LENGTH, + PROFILE_NAME_MAX_LENGTH, + PROFILE_SAVE_CONTROL_TOTAL_LENGTH, + PROFILE_SAVE_DATA_CHUNK_BYTES, + PROFILE_SAVE_DATA_TOTAL_LENGTH, + PROFILE_SAVE_OPCODE_BEGIN, + PROFILE_SAVE_OPCODE_CANCEL, + PROFILE_SAVE_OPCODE_COMMIT, + PROFILE_SAVE_SECTION_PROFILE_TEXT, + PROFILE_SAVE_SECTION_WAVEFORM_TEXT, SEND_PARAMS_TOTAL_LENGTH, - TASK_ENABLE_COMMAND_LENGTH, - CMD_DECODE_ENABLE, CMD_DEFAULT_ENABLE, - CMD_TRANS_ENABLE, CMD_REMOVE_FILE, - CMD_STATE, CMD_TASK_ENABLE, - STATE_DESCRIPTIONS, STATE_OK, + SHORT_CONTROL_TOTAL_LENGTH, + STM32_DAC_FLAG_ENABLE, + STATUS_DESCRIPTIONS, + STATUS_RESPONSE_LENGTH, + WAVE_DATA_TOTAL_LENGTH, ) from .conversions import ( - temp_c_to_n, temp_n_to_c, + current_ma_to_n, + current_n_to_ma, + temp_c_to_n, temp_ext_n_to_c, - current_ma_to_n, current_n_to_ma, - voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v, -) -from .models import Measurements, VariationType -from .exceptions import ( - CommunicationError, - PortNotFoundError, - CRCError, - ProtocolError, + temp_n_to_c, + voltage_3v3_n_to_v, + voltage_5v_n_to_v, + voltage_7v_n_to_v, ) +from .exceptions import CRCError, ProtocolError +from .models import DeviceState, Measurements -# Re-export enums so tests can import from protocol module -class CommandCode(IntEnum): - DECODE_ENABLE = CMD_DECODE_ENABLE - DEFAULT_ENABLE = CMD_DEFAULT_ENABLE - TRANS_ENABLE = CMD_TRANS_ENABLE - REMOVE_FILE = CMD_REMOVE_FILE - STATE = CMD_STATE - TASK_ENABLE = CMD_TASK_ENABLE - - -class TaskType(IntEnum): - MANUAL = 0x00 - CHANGE_CURRENT_LD1 = 0x01 - CHANGE_CURRENT_LD2 = 0x02 - CHANGE_TEMPERATURE_LD1 = 0x03 - CHANGE_TEMPERATURE_LD2 = 0x04 - - -class DeviceState(IntEnum): - IDLE = 0x0000 - RUNNING = 0x0001 - BUSY = 0x0002 - ERROR = 0x00FF - ERROR_OVERHEAT = 0x0100 - ERROR_POWER = 0x0200 - ERROR_COMMUNICATION = 0x0400 - ERROR_INVALID_COMMAND = 0x0800 - - -# ---- Low-level helpers -------------------------------------------------- - def _int_to_hex4(value: int) -> str: - """Return 4-character lowercase hex string (0–65535).""" - if value < 0 or value > 65535: - raise ValueError(f"Value {value} out of uint16 range [0, 65535]") + """Return a zero-padded four-digit lowercase hex string.""" + if value < 0 or value > 0xFFFF: + raise ValueError(f"Value {value} out of uint16 range") return f"{value:04x}" -def _flipfour(s: str) -> str: - """Swap two byte-pairs: 'aabb' → 'bbaa' (little-endian word).""" - if len(s) != 4: - raise ValueError(f"Expected 4-char hex string, got '{s}'") - return s[2:4] + s[0:2] - - -def _xor_crc(words: list) -> str: - """XOR all 16-bit hex words and return 4-char hex CRC.""" - result = int(words[0], 16) - for w in words[1:]: - result ^= int(w, 16) - return _int_to_hex4(result) +def _flipfour(value: str) -> str: + """Swap byte pairs in a four-character hex word.""" + if len(value) != 4: + raise ValueError(f"Expected 4 hex chars, got {value!r}") + return value[2:4] + value[0:2] def _build_crc(data_hex: str) -> str: - """Calculate XOR CRC over words 1..N of a hex string (skip word 0).""" - words = [data_hex[i:i+4] for i in range(0, len(data_hex), 4)] - return _xor_crc(words[1:]) + """Return the checksum word for a wire-order hex packet without CRC.""" + if len(data_hex) % 4 != 0: + raise ValueError("Packet hex string must contain complete 16-bit words") + words = [data_hex[index:index + 4] for index in range(0, len(data_hex), 4)] + checksum = 0 + for word in words[1:]: + checksum ^= int(word, 16) + return _int_to_hex4(checksum) -def _encode_setup() -> str: - """Build the 16-bit setup word (all subsystems enabled, SD save off).""" - bits = ['0'] * 16 - bits[15] = '1' # enable work - bits[14] = '1' # enable 5v1 - bits[13] = '1' # enable 5v2 - bits[12] = '1' # enable LD1 - bits[11] = '1' # enable LD2 - bits[10] = '1' # enable REF1 - bits[9] = '1' # enable REF2 - bits[8] = '1' # enable TEC1 - bits[7] = '1' # enable TEC2 - bits[6] = '1' # enable temp stab 1 - bits[5] = '1' # enable temp stab 2 - bits[4] = '0' # enable sd save (disabled) - bits[3] = '1' # enable PI1 coef read - bits[2] = '1' # enable PI2 coef read - bits[1] = '0' # reserved - bits[0] = '0' # reserved - return f"{int(''.join(bits), 2):04x}" +def _pack_words(words: list[int]) -> bytes: + return struct.pack("<" + "H" * len(words), *words) -# ---- Response dataclass -------------------------------------------------- +def _unpack_words(data: bytes) -> tuple[int, ...]: + if len(data) % 2 != 0: + raise ProtocolError(f"Packet length must be even, got {len(data)} bytes") + return struct.unpack("<" + "H" * (len(data) // 2), data) -class Response: - """Decoded device DATA response.""" - __slots__ = [ - 'current1', 'current2', - 'temp1', 'temp2', - 'temp_ext1', 'temp_ext2', - 'voltage_3v3', 'voltage_5v1', 'voltage_5v2', 'voltage_7v0', - 'to6_lsb', 'to6_msb', - 'message_id', - 'header', - ] - def to_measurements(self) -> Measurements: - return Measurements( - current1=self.current1, - current2=self.current2, - temp1=self.temp1, - temp2=self.temp2, - temp_ext1=self.temp_ext1, - temp_ext2=self.temp_ext2, - voltage_3v3=self.voltage_3v3, - voltage_5v1=self.voltage_5v1, - voltage_5v2=self.voltage_5v2, - voltage_7v0=self.voltage_7v0, - timestamp=datetime.now(), - message_id=self.message_id, - to6_counter_lsb=self.to6_lsb, - to6_counter_msb=self.to6_msb, +def _payload_checksum(words: list[int]) -> int: + checksum = 0 + for word in words: + checksum ^= word + return checksum & 0xFFFF + + +def _ensure_uint(value: int, name: str, minimum: int, maximum: int) -> int: + if not isinstance(value, int): + raise ValueError(f"{name} must be an integer") + if not minimum <= value <= maximum: + raise ValueError(f"{name} must be in range [{minimum}, {maximum}]") + return value + + +def _encode_ascii_name_words(profile_name: str) -> tuple[list[int], int]: + if not isinstance(profile_name, str): + raise ValueError("profile_name must be a string") + + try: + encoded = profile_name.encode("ascii") + except UnicodeEncodeError as exc: + raise ValueError("profile_name must contain ASCII characters only") from exc + + if not 1 <= len(encoded) <= PROFILE_NAME_MAX_LENGTH: + raise ValueError( + f"profile_name length must be in range [1, {PROFILE_NAME_MAX_LENGTH}]" ) + padded = encoded + (b"\x00" * (PROFILE_NAME_MAX_LENGTH - len(encoded))) + words = [ + padded[index] | (padded[index + 1] << 8) + for index in range(0, PROFILE_NAME_MAX_LENGTH, 2) + ] + return words, len(encoded) -# ---- Message builder -------------------------------------------------- - -class Message: - """Named container for an encoded command byte array.""" - def __init__(self, data: bytearray): - self._data = data - - def to_bytes(self) -> bytes: - return bytes(self._data) - - def __len__(self): - return len(self._data) - - -# ---- Protocol class -------------------------------------------------- class Protocol: - """ - Encodes commands and decodes responses for the laser control board. - - Can also manage a serial port connection when port is provided. - """ - - def __init__(self, port: Optional[str] = None): - self._port_name = port - self._serial: Optional[serial.Serial] = None - - # ---- Connection management - - def connect(self) -> None: - """Open the serial port. Auto-detects if port is None.""" - port = self._port_name or self._detect_port() - try: - self._serial = serial.Serial( - port=port, - baudrate=BAUDRATE, - timeout=SERIAL_TIMEOUT_SEC, - ) - except Exception as exc: - raise CommunicationError( - f"Cannot connect to port '{port}': {exc}" - ) from exc - - def disconnect(self) -> None: - """Close the serial port if open.""" - if self._serial and self._serial.is_open: - self._serial.close() - - @property - def is_connected(self) -> bool: - return self._serial is not None and self._serial.is_open - - def _detect_port(self) -> str: - """Return first available serial port device path.""" - ports = list(serial.tools.list_ports.comports()) - if not ports: - raise PortNotFoundError() - return ports[0].device - - # ---- Raw I/O - - def send_raw(self, data: bytes) -> None: - if self._serial is None or not self._serial.is_open: - raise CommunicationError("Serial port is not connected") - self._serial.write(data) - - def receive_raw(self, length: int) -> bytes: - if self._serial is None or not self._serial.is_open: - raise CommunicationError("Serial port is not connected") - return self._serial.read(length) - - # ---- Static encoding helpers (no connection required) --------------- - - @staticmethod - def flipfour(value: int) -> int: - """Byte-swap a 16-bit integer (little-endian word swap).""" - return ((value & 0xFF) << 8) | ((value >> 8) & 0xFF) - - @staticmethod - def pack_float(value: float) -> bytes: - return struct.pack(' bytes: - return struct.pack(' int: - """ - XOR CRC over all 16-bit words except the last two bytes (CRC field). - Mirrors the original CalculateCRC logic. - """ - hex_str = data.hex() - words = [hex_str[i:i+4] for i in range(0, len(hex_str), 4)] - # Skip word 0 (command code) per original firmware expectation - crc_words = words[1:] - result = int(crc_words[0], 16) - for w in crc_words[1:]: - result ^= int(w, 16) - return result - - # ---- Command encoders ----------------------------------------------- + """Calculate XOR checksum over all words except the first header word.""" + words = _unpack_words(data) + if len(words) <= 1: + return 0 + return _payload_checksum(list(words[1:])) @staticmethod def encode_decode_enable( @@ -268,188 +159,328 @@ class Protocol: pi_coeff2_i: int, message_id: int, ) -> bytes: - """ - Build DECODE_ENABLE command (0x1111). - - Sets temperature and current setpoints for both lasers. - Returns 30-byte bytearray. - """ - if current1 < 0 or current2 < 0: - raise ValueError("Current values must not be negative") - - data = _flipfour(_int_to_hex4(CMD_DECODE_ENABLE)) # Word 0 - data += _flipfour(_encode_setup()) # Word 1 - data += _flipfour(_int_to_hex4(temp_c_to_n(temp1))) # Word 2 - data += _flipfour(_int_to_hex4(temp_c_to_n(temp2))) # Word 3 - data += _flipfour('0000') * 3 # Words 4-6 - data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 7 - data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 8 - data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 9 - data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 10 - data += _flipfour(_int_to_hex4(message_id & 0xFFFF)) # Word 11 - data += _flipfour(_int_to_hex4(current_ma_to_n(current1))) # Word 12 - data += _flipfour(_int_to_hex4(current_ma_to_n(current2))) # Word 13 - data += _build_crc(data) # Word 14 - - result = bytearray.fromhex(data) - assert len(result) == SEND_PARAMS_TOTAL_LENGTH, \ - f"DECODE_ENABLE length mismatch: {len(result)}" - return bytes(result) + """Build the 30-byte DECODE_ENABLE command.""" + words = [ + CMD_DECODE_ENABLE, + DEFAULT_SETUP_WORD, + temp_c_to_n(temp1), + temp_c_to_n(temp2), + 0, + 0, + 0, + pi_coeff1_p & 0xFFFF, + pi_coeff1_i & 0xFFFF, + pi_coeff2_p & 0xFFFF, + pi_coeff2_i & 0xFFFF, + message_id & 0xFFFF, + current_ma_to_n(current1), + current_ma_to_n(current2), + ] + words.append(_payload_checksum(words[1:])) + packet = _pack_words(words) + if len(packet) != SEND_PARAMS_TOTAL_LENGTH: + raise ProtocolError( + f"DECODE_ENABLE length mismatch: {len(packet)} bytes" + ) + return packet @staticmethod - def encode_task_enable( - task_type: TaskType, - static_temp1: float, - static_temp2: float, - static_current1: float, - static_current2: float, - min_value: float, - max_value: float, - step: float, - time_step: int, - delay_time: int, - message_id: int, - pi_coeff1_p: int = 1, - pi_coeff1_i: int = 1, - pi_coeff2_p: int = 1, - pi_coeff2_i: int = 1, + def encode_trans_enable() -> bytes: + """Build the short TRANS_ENABLE command.""" + return _pack_words([CMD_TRANS_ENABLE]) + + @staticmethod + def encode_state() -> bytes: + """Build the short STATE command.""" + return _pack_words([CMD_STATE]) + + @staticmethod + def encode_default_enable() -> bytes: + """Build the short DEFAULT_ENABLE command.""" + return _pack_words([CMD_DEFAULT_ENABLE]) + + @staticmethod + def encode_ad9102_control( + *, + enabled: bool, + triangle: bool, + sram_mode: bool, + param0: int, + param1: int, + alt_format: bool = False, ) -> bytes: - """ - Build TASK_ENABLE command (0x7777). - - Starts a measurement task (current or temperature variation). - Returns 32-byte bytearray. - """ - if not isinstance(task_type, TaskType): - try: - task_type = TaskType(task_type) - except ValueError: - raise ValueError(f"Invalid task_type: {task_type}") - - data = _flipfour(_int_to_hex4(CMD_TASK_ENABLE)) # Word 0 - data += _flipfour(_encode_setup()) # Word 1 - data += _flipfour(_int_to_hex4(task_type.value)) # Word 2 - - match task_type: - case TaskType.CHANGE_CURRENT_LD1: - data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3 - data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4 - data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5 - data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100 - data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 7 - data += _flipfour(_int_to_hex4(current_ma_to_n(static_current2)))# Word 8 - data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 9 - case TaskType.CHANGE_CURRENT_LD2: - data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3 - data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4 - data += _flipfour(_int_to_hex4(int(step * 100))) # Word 5 - data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100 - data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7 - data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8 - data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 9 - case TaskType.CHANGE_TEMPERATURE_LD1 | TaskType.CHANGE_TEMPERATURE_LD2: - raise NotImplementedError("Temperature variation is not yet implemented in firmware") - case _: - raise ValueError(f"Unsupported task type: {task_type}") - - data += _flipfour(_int_to_hex4(int(delay_time))) # Word 10: Tau in ms (3-10) - data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 11 - data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 12 - data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 13 - data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 14 - data += _build_crc(data) # Word 15 - - result = bytearray.fromhex(data) - assert len(result) == TASK_ENABLE_COMMAND_LENGTH, \ - f"TASK_ENABLE length mismatch: {len(result)}" - return bytes(result) + """Build an AD9102 control packet.""" + flags = 0 + if enabled: + flags |= AD9102_FLAG_ENABLE + if triangle: + flags |= AD9102_FLAG_TRIANGLE + if sram_mode: + flags |= AD9102_FLAG_SRAM + if alt_format: + flags |= AD9102_FLAG_SRAM_FORMAT_ALT + return Protocol._encode_short_control( + CMD_AD9102_CONTROL, + flags, + _ensure_uint(param0, "param0", 0, 0xFFFF), + _ensure_uint(param1, "param1", 0, 0xFFFF), + ) @staticmethod - def encode_trans_enable(message_id: int = 0) -> bytes: - """Build TRANS_ENABLE command (0x4444) — request last data.""" - return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE))) + def encode_ad9833_control(*, enabled: bool, triangle: bool, frequency_word: int) -> bytes: + """Build an AD9833 control packet.""" + flags = 0 + if enabled: + flags |= AD9833_FLAG_ENABLE + if triangle: + flags |= AD9833_FLAG_TRIANGLE + frequency_word = _ensure_uint(frequency_word, "frequency_word", 0, 0x0FFFFFFF) + return Protocol._encode_short_control( + CMD_AD9833_CONTROL, + flags, + frequency_word & 0x3FFF, + (frequency_word >> 14) & 0x3FFF, + ) @staticmethod - def encode_state(message_id: int = 0) -> bytes: - """Build STATE command (0x6666) — request device state.""" - return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_STATE))) + def encode_ds1809_control(*, increment: bool, decrement: bool, count: int, pulse_ms: int) -> bytes: + """Build a DS1809 control packet.""" + if increment and decrement: + raise ValueError("increment and decrement cannot both be true") + flags = 0 + if increment: + flags |= DS1809_FLAG_INCREMENT + if decrement: + flags |= DS1809_FLAG_DECREMENT + return Protocol._encode_short_control( + CMD_DS1809_CONTROL, + flags, + _ensure_uint(count, "count", 0, 0xFFFF), + _ensure_uint(pulse_ms, "pulse_ms", 0, 0xFFFF), + ) @staticmethod - def encode_default_enable(message_id: int = 0) -> bytes: - """Build DEFAULT_ENABLE command (0x2222) — reset device.""" - return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE))) + def encode_stm32_dac_control(*, enabled: bool, dac_code: int) -> bytes: + """Build an STM32 DAC control packet.""" + flags = STM32_DAC_FLAG_ENABLE if enabled else 0 + return Protocol._encode_short_control( + CMD_STM32_DAC_CONTROL, + flags, + _ensure_uint(dac_code, "dac_code", 0, 0x0FFF), + 0, + ) @staticmethod - def encode_remove_file() -> bytes: - """Build REMOVE_FILE command (0x5555) — delete saved data.""" - return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_REMOVE_FILE))) - - # ---- Response decoders ----------------------------------------------- + def encode_ad9102_wave_begin(sample_count: int) -> bytes: + """Build an AD9102 custom-wave upload BEGIN packet.""" + return Protocol._encode_short_control( + CMD_AD9102_WAVE_CONTROL, + AD9102_WAVE_OPCODE_BEGIN, + _ensure_uint(sample_count, "sample_count", 0, 0xFFFF), + 0, + ) @staticmethod - def decode_response(data: bytes) -> Response: - """ - Decode a 30-byte DATA response from the device. + def encode_ad9102_wave_commit() -> bytes: + """Build an AD9102 custom-wave upload COMMIT packet.""" + return Protocol._encode_short_control( + CMD_AD9102_WAVE_CONTROL, + AD9102_WAVE_OPCODE_COMMIT, + 0, + 0, + ) - Raises: - ProtocolError: If data length is wrong. - CRCError: If CRC check fails. - """ + @staticmethod + def encode_ad9102_wave_cancel() -> bytes: + """Build an AD9102 custom-wave upload CANCEL packet.""" + return Protocol._encode_short_control( + CMD_AD9102_WAVE_CONTROL, + AD9102_WAVE_OPCODE_CANCEL, + 0, + 0, + ) + + @staticmethod + def encode_ad9102_wave_data(samples: list[int]) -> bytes: + """Build one fixed-size AD9102 custom-wave data chunk packet.""" + if not samples: + raise ValueError("samples must not be empty") + if len(samples) > AD9102_WAVE_MAX_CHUNK_SAMPLES: + raise ValueError( + f"samples length must be <= {AD9102_WAVE_MAX_CHUNK_SAMPLES}" + ) + + encoded_samples = [] + for index, sample in enumerate(samples): + if not isinstance(sample, int): + raise ValueError(f"sample[{index}] must be an integer") + if not AD9102_WAVE_SAMPLE_MIN <= sample <= AD9102_WAVE_SAMPLE_MAX: + raise ValueError( + f"sample[{index}] must be in range " + f"[{AD9102_WAVE_SAMPLE_MIN}, {AD9102_WAVE_SAMPLE_MAX}]" + ) + encoded_samples.append(sample & 0xFFFF) + + padded_samples = encoded_samples + [0] * (AD9102_WAVE_MAX_CHUNK_SAMPLES - len(samples)) + words = [CMD_AD9102_WAVE_DATA, len(samples), *padded_samples] + words.append(_payload_checksum(words[1:])) + packet = _pack_words(words) + if len(packet) != WAVE_DATA_TOTAL_LENGTH: + raise ProtocolError(f"AD9102_WAVE_DATA length mismatch: {len(packet)} bytes") + return packet + + @staticmethod + def encode_profile_save_begin( + *, + profile_name: str, + profile_text_bytes: int, + waveform_text_bytes: int, + ) -> bytes: + """Build the fixed-size BEGIN packet for a streamed SD profile save.""" + name_words, name_length = _encode_ascii_name_words(profile_name) + payload_words = [ + PROFILE_SAVE_OPCODE_BEGIN, + _ensure_uint(profile_text_bytes, "profile_text_bytes", 1, 0xFFFF), + _ensure_uint(waveform_text_bytes, "waveform_text_bytes", 0, 0xFFFF), + name_length, + *name_words, + 0, + ] + payload_words.append(_payload_checksum(payload_words)) + packet = _pack_words([CMD_PROFILE_SAVE_CONTROL, *payload_words]) + if len(packet) != PROFILE_SAVE_CONTROL_TOTAL_LENGTH: + raise ProtocolError( + f"PROFILE_SAVE_BEGIN length mismatch: {len(packet)} bytes" + ) + return packet + + @staticmethod + def encode_profile_save_commit() -> bytes: + """Build the fixed-size COMMIT packet for a streamed SD profile save.""" + payload_words = [PROFILE_SAVE_OPCODE_COMMIT] + ([0] * 12) + payload_words.append(_payload_checksum(payload_words)) + packet = _pack_words([CMD_PROFILE_SAVE_CONTROL, *payload_words]) + if len(packet) != PROFILE_SAVE_CONTROL_TOTAL_LENGTH: + raise ProtocolError( + f"PROFILE_SAVE_COMMIT length mismatch: {len(packet)} bytes" + ) + return packet + + @staticmethod + def encode_profile_save_cancel() -> bytes: + """Build the fixed-size CANCEL packet for a streamed SD profile save.""" + payload_words = [PROFILE_SAVE_OPCODE_CANCEL] + ([0] * 12) + payload_words.append(_payload_checksum(payload_words)) + packet = _pack_words([CMD_PROFILE_SAVE_CONTROL, *payload_words]) + if len(packet) != PROFILE_SAVE_CONTROL_TOTAL_LENGTH: + raise ProtocolError( + f"PROFILE_SAVE_CANCEL length mismatch: {len(packet)} bytes" + ) + return packet + + @staticmethod + def encode_profile_save_data(*, section_id: int, chunk: bytes) -> bytes: + """Build one fixed-size data packet carrying profile or waveform text.""" + if not isinstance(chunk, (bytes, bytearray)): + raise ValueError("chunk must be bytes") + if not chunk: + raise ValueError("chunk must not be empty") + if len(chunk) > PROFILE_SAVE_DATA_CHUNK_BYTES: + raise ValueError( + f"chunk length must be <= {PROFILE_SAVE_DATA_CHUNK_BYTES}" + ) + if section_id not in ( + PROFILE_SAVE_SECTION_PROFILE_TEXT, + PROFILE_SAVE_SECTION_WAVEFORM_TEXT, + ): + raise ValueError("section_id is invalid") + + padded = bytes(chunk) + (b"\x00" * (PROFILE_SAVE_DATA_CHUNK_BYTES - len(chunk))) + data_words = [ + padded[index] | (padded[index + 1] << 8) + for index in range(0, PROFILE_SAVE_DATA_CHUNK_BYTES, 2) + ] + payload_words = [section_id, len(chunk), *data_words] + payload_words.append(_payload_checksum(payload_words)) + packet = _pack_words([CMD_PROFILE_SAVE_DATA, *payload_words]) + if len(packet) != PROFILE_SAVE_DATA_TOTAL_LENGTH: + raise ProtocolError( + f"PROFILE_SAVE_DATA length mismatch: {len(packet)} bytes" + ) + return packet + + @staticmethod + def _encode_short_control(header: int, word0: int, word1: int, word2: int) -> bytes: + words = [header, word0 & 0xFFFF, word1 & 0xFFFF, word2 & 0xFFFF] + words.append(_payload_checksum(words[1:])) + packet = _pack_words(words) + if len(packet) != SHORT_CONTROL_TOTAL_LENGTH: + raise ProtocolError(f"Short control length mismatch: {len(packet)} bytes") + return packet + + @staticmethod + def decode_response(data: bytes) -> Measurements: + """Decode a 30-byte telemetry frame into a Measurements object.""" if len(data) != GET_DATA_TOTAL_LENGTH: raise ProtocolError( f"Expected {GET_DATA_TOTAL_LENGTH} bytes, got {len(data)} bytes" ) - hex_str = data.hex() + words = _unpack_words(data) + expected_crc = _payload_checksum(list(words[1:14])) + if words[14] != expected_crc: + raise CRCError(expected=expected_crc, received=words[14]) - def get_word(num: int) -> str: - return _flipfour(hex_str[num*4: num*4+4]) + return Measurements( + current1=current_n_to_ma(words[1]), + current2=current_n_to_ma(words[2]), + temp1=temp_n_to_c(words[5]), + temp2=temp_n_to_c(words[6]), + temp_ext1=temp_ext_n_to_c(words[7]), + temp_ext2=temp_ext_n_to_c(words[8]), + voltage_3v3=voltage_3v3_n_to_v(words[9]), + voltage_5v1=voltage_5v_n_to_v(words[10]), + voltage_5v2=voltage_5v_n_to_v(words[11]), + voltage_7v0=voltage_7v_n_to_v(words[12]), + message_id=words[13], + to6_counter_lsb=words[3], + to6_counter_msb=words[4], + timestamp=datetime.now(), + ) - def get_int_word(num: int) -> int: - return int(get_word(num), 16) - - # CRC check: XOR over words 1..13 (wire order), compare with word 14 (wire order) - crc_words = [hex_str[i:i+4] for i in range(4, len(hex_str)-4, 4)] - computed = int(crc_words[0], 16) - for w in crc_words[1:]: - computed ^= int(w, 16) - stored = int(hex_str[56:60], 16) - if computed != stored: - raise CRCError(expected=computed, received=stored) - - resp = Response() - resp.header = get_word(0) - resp.current1 = current_n_to_ma(get_int_word(1)) - resp.current2 = current_n_to_ma(get_int_word(2)) - resp.to6_lsb = get_int_word(3) - resp.to6_msb = get_int_word(4) - resp.temp1 = temp_n_to_c(get_int_word(5)) - resp.temp2 = temp_n_to_c(get_int_word(6)) - resp.temp_ext1 = temp_ext_n_to_c(get_int_word(7)) - resp.temp_ext2 = temp_ext_n_to_c(get_int_word(8)) - resp.voltage_3v3 = voltage_3v3_n_to_v(get_int_word(9)) - resp.voltage_5v1 = voltage_5v_n_to_v(get_int_word(10)) - resp.voltage_5v2 = voltage_5v_n_to_v(get_int_word(11)) - resp.voltage_7v0 = voltage_7v_n_to_v(get_int_word(12)) - resp.message_id = get_int_word(13) - - return resp + @staticmethod + def decode_status(data: bytes) -> tuple[DeviceState, int]: + """Decode the two-byte firmware status response into flags and detail.""" + if len(data) != STATUS_RESPONSE_LENGTH: + raise ProtocolError( + f"Expected {STATUS_RESPONSE_LENGTH} status bytes, got {len(data)}" + ) + raw_word = _unpack_words(data)[0] + flags = DeviceState(raw_word & 0x00FF) + detail = (raw_word >> 8) & 0x00FF + return flags, detail @staticmethod def decode_state(data: bytes) -> int: - """ - Decode a 2-byte STATE response from the device. - - Returns: - Integer state code (compare with DeviceState enum). - """ - if len(data) < 2: - raise ProtocolError(f"STATE response too short: {len(data)} bytes") - hex_str = data.hex() - state_hex = _flipfour(hex_str[0:4]) - return int(state_hex, 16) + """Compatibility helper returning only the low-byte status mask.""" + flags, _detail = Protocol.decode_status(data) + return int(flags) @staticmethod - def state_to_description(state_hex_str: str) -> str: - """Return human-readable description for a state hex string.""" - return STATE_DESCRIPTIONS.get(state_hex_str, "Unknown or reserved error.") \ No newline at end of file + def state_to_description(state: DeviceState | int) -> str: + """Return a readable description for a status mask.""" + state = DeviceState(int(state)) + if state == DeviceState.OK: + return "All ok." + + parts = [ + text + for mask, text in STATUS_DESCRIPTIONS.items() + if (state & DeviceState(mask)) == DeviceState(mask) + ] + if parts: + return "; ".join(parts) + return f"Unknown status mask: 0x{int(state):02X}" + + +__all__ = ["Protocol", "_build_crc", "_flipfour", "_int_to_hex4"] diff --git a/laser_control/transport.py b/laser_control/transport.py new file mode 100644 index 0000000..d4cc9f0 --- /dev/null +++ b/laser_control/transport.py @@ -0,0 +1,78 @@ +"""Serial transport for the laser controller board.""" + +from __future__ import annotations + +import serial +import serial.tools.list_ports + +from .constants import BAUDRATE, SERIAL_TIMEOUT_SEC +from .exceptions import CommunicationError, PortNotFoundError + + +class SerialTransport: + """Small serial wrapper with auto-detection and explicit lifecycle.""" + + def __init__( + self, + port: str | None = None, + baudrate: int = BAUDRATE, + timeout: float = SERIAL_TIMEOUT_SEC, + ) -> None: + self._requested_port = port + self._active_port: str | None = None + self._baudrate = baudrate + self._timeout = timeout + self._serial: serial.Serial | None = None + + @property + def port_name(self) -> str | None: + """Return the connected port or the requested port when disconnected.""" + return self._active_port or self._requested_port + + @property + def is_connected(self) -> bool: + """Return True when the serial port is currently open.""" + return self._serial is not None and self._serial.is_open + + def connect(self) -> None: + """Open the serial port, auto-detecting the first USB port when needed.""" + port = self._requested_port or self._detect_port() + try: + self._serial = serial.Serial( + port=port, + baudrate=self._baudrate, + timeout=self._timeout, + ) + except Exception as exc: # noqa: BLE001 + raise CommunicationError(f"Cannot connect to port '{port}': {exc}") from exc + self._active_port = port + + def disconnect(self) -> None: + """Close the serial port if it is open.""" + if self._serial is not None and self._serial.is_open: + self._serial.close() + self._serial = None + + def send(self, data: bytes) -> None: + """Write raw bytes to the serial port.""" + if not self.is_connected: + raise CommunicationError("Serial port is not connected") + assert self._serial is not None + self._serial.write(data) + + def read(self, length: int) -> bytes: + """Read a fixed number of bytes from the serial port.""" + if not self.is_connected: + raise CommunicationError("Serial port is not connected") + assert self._serial is not None + return self._serial.read(length) + + def _detect_port(self) -> str: + ports = sorted(serial.tools.list_ports.comports(), key=lambda port: port.device) + if not ports: + raise PortNotFoundError() + + usb_ports = [port.device for port in ports if "USB" in port.device.upper()] + if usb_ports: + return usb_ports[0] + return ports[0].device diff --git a/laser_control/validators.py b/laser_control/validators.py index 98aa288..d4178b9 100644 --- a/laser_control/validators.py +++ b/laser_control/validators.py @@ -1,20 +1,14 @@ -""" -Parameter validation for laser control module. - -Validates all input parameters against physical constraints -and protocol limits before sending to device. -""" +"""Validation helpers for controller inputs.""" import math -from typing import Dict, Any, Tuple +import re +from typing import Any from .constants import ( TEMP_MIN_C, TEMP_MAX_C, CURRENT_MIN_MA, CURRENT_MAX_MA, - CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA, - TEMP_STEP_MIN_C, TEMP_STEP_MAX_C, - TIME_STEP_MIN_US, TIME_STEP_MAX_US, - DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS, + PROFILE_NAME_ALLOWED_PATTERN, + PROFILE_NAME_MAX_LENGTH, ) from .exceptions import ( ValidationError, @@ -22,7 +16,6 @@ from .exceptions import ( CurrentOutOfRangeError, InvalidParameterError, ) -from .models import VariationType class ParameterValidator: @@ -87,152 +80,13 @@ class ParameterValidator: ) return value - @staticmethod - def validate_time_params(time_step: Any, delay_time: Any) -> Tuple[int, int]: - """ - Validate time parameters for variation mode. - - Args: - time_step: Discretisation time step in microseconds. - delay_time: Delay between pulses in milliseconds. - - Returns: - Tuple (time_step, delay_time) as integers. - - Raises: - InvalidParameterError: If values are not numeric. - ValidationError: If values are outside allowed ranges. - """ - if not isinstance(time_step, (int, float)): - raise InvalidParameterError("time_step", "Value must be a number") - if not isinstance(delay_time, (int, float)): - raise InvalidParameterError("delay_time", "Value must be a number") - - time_step_int = int(time_step) - delay_time_int = int(delay_time) - - if time_step_int < TIME_STEP_MIN_US or time_step_int > TIME_STEP_MAX_US: - raise ValidationError( - f"time step {time_step_int} µs is out of range " - f"[{TIME_STEP_MIN_US} - {TIME_STEP_MAX_US}] µs" - ) - if delay_time_int < DELAY_TIME_MIN_MS or delay_time_int > DELAY_TIME_MAX_MS: - raise ValidationError( - f"delay time {delay_time_int} ms is out of range " - f"[{DELAY_TIME_MIN_MS} - {DELAY_TIME_MAX_MS}] ms" - ) - return time_step_int, delay_time_int - - @staticmethod - def validate_variation_params( - params: Dict[str, Any], - variation_type: Any - ) -> Dict[str, Any]: - """ - Validate parameters for variation mode. - - Args: - params: Dictionary with keys: - min_value, max_value, step, time_step, delay_time. - variation_type: A VariationType enum value. - - Returns: - Dictionary with validated and type-coerced values. - - Raises: - ValidationError: For any constraint violation. - InvalidParameterError: For wrong types. - """ - # Validate variation type - if not isinstance(variation_type, VariationType): - try: - variation_type = VariationType(variation_type) - except (ValueError, KeyError): - raise ValidationError( - f"Invalid variation type '{variation_type}'. " - f"Must be one of {[e.name for e in VariationType]}" - ) - - # Check required keys - required_keys = {'min_value', 'max_value', 'step', 'time_step', 'delay_time'} - missing = required_keys - params.keys() - if missing: - raise ValidationError( - f"Missing required parameters: {sorted(missing)}" - ) - - # Validate min/max - min_val = ParameterValidator._check_numeric(params['min_value'], 'min_value') - max_val = ParameterValidator._check_numeric(params['max_value'], 'max_value') - - if min_val >= max_val: - raise ValidationError( - f"min_value ({min_val}) must be less than max_value ({max_val})" - ) - - # Validate step based on variation type - step = ParameterValidator._check_numeric(params['step'], 'step') - - is_current_variation = variation_type in ( - VariationType.CHANGE_CURRENT_LD1, - VariationType.CHANGE_CURRENT_LD2 - ) - is_temp_variation = variation_type in ( - VariationType.CHANGE_TEMPERATURE_LD1, - VariationType.CHANGE_TEMPERATURE_LD2 - ) - - if is_current_variation: - step_min, step_max = CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA - unit = "mA" - # Also validate range against current limits - ParameterValidator.validate_current(min_val, 'min_value') - ParameterValidator.validate_current(max_val, 'max_value') - elif is_temp_variation: - step_min, step_max = TEMP_STEP_MIN_C, TEMP_STEP_MAX_C - unit = "°C" - # Also validate range against temperature limits - ParameterValidator.validate_temperature(min_val, 'min_value') - ParameterValidator.validate_temperature(max_val, 'max_value') - else: - raise ValidationError( - f"Variation type {variation_type.name} cannot be used in variation mode" - ) - - if step <= 0: - raise ValidationError( - f"step must be positive, got {step} {unit}" - ) - if step < step_min: - raise ValidationError( - f"step {step} {unit} is too small (minimum {step_min} {unit})" - ) - if step > step_max: - raise ValidationError( - f"step {step} {unit} is too large (maximum {step_max} {unit})" - ) - - # Validate time parameters - time_step, delay_time = ParameterValidator.validate_time_params( - params['time_step'], params['delay_time'] - ) - - return { - 'variation_type': variation_type, - 'min_value': min_val, - 'max_value': max_val, - 'step': step, - 'time_step': time_step, - 'delay_time': delay_time, - } - @staticmethod def validate_manual_mode_params( temp1: Any, temp2: Any, current1: Any, current2: Any, - ) -> Dict[str, float]: + ) -> dict[str, float]: """ Validate all four manual mode parameters. @@ -254,4 +108,26 @@ class ParameterValidator: 'temp2': ParameterValidator.validate_temperature(temp2, 'temp2'), 'current1': ParameterValidator.validate_current(current1, 'current1'), 'current2': ParameterValidator.validate_current(current2, 'current2'), - } \ No newline at end of file + } + + @staticmethod + def validate_profile_name(value: Any) -> str: + """Validate a short ASCII profile name suitable for the device LCD.""" + if not isinstance(value, str): + raise InvalidParameterError("profile_name", "Value must be a string") + + normalized = value.strip() + if not normalized: + raise InvalidParameterError("profile_name", "Value must not be empty") + if len(normalized) > PROFILE_NAME_MAX_LENGTH: + raise InvalidParameterError( + "profile_name", + f"Value must be at most {PROFILE_NAME_MAX_LENGTH} characters long", + ) + if re.fullmatch(PROFILE_NAME_ALLOWED_PATTERN, normalized) is None: + raise InvalidParameterError( + "profile_name", + "Only ASCII letters, digits, spaces, '-' and '_' are allowed", + ) + + return normalized diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f7889cf..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,12 +0,0 @@ -[build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" - -[project] -name = "laser_control" -version = "1.0.0" -dependencies = ["pyserial"] - -[tool.setuptools.packages.find] -where = ["."] -include = ["laser_control*"] diff --git a/requirements.txt b/requirements.txt index ee1a49a..5a1083f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,3 @@ -FreeSimpleGUI==5.2.0.post1 -iniconfig==2.3.0 -packaging==26.0 -pluggy==1.6.0 -Pygments==2.19.2 +PyQt6>=6.6 +pyqtgraph>=0.13 pyserial==3.5 -pytest==9.0.2 \ No newline at end of file diff --git a/run b/run index ff65df4..375af25 100755 --- a/run +++ b/run @@ -1,7 +1,4 @@ #!/usr/bin/bash -#reset generator PCB -# pinctrl set 26 op dl # drive PCB NRST LOW -> reset stm32 -# pinctrl set 26 op dh # turn stm32 back ON source .venv/bin/activate -python3 _device_main.py +python3 -m laser_control.gui.main diff --git a/run_device_main.bat b/run_device_main.bat index 1239281..8d5e9ac 100644 --- a/run_device_main.bat +++ b/run_device_main.bat @@ -33,8 +33,8 @@ if exist "requirements.txt" ( echo requirements.txt not found. Skipping dependency installation. ) -echo Starting _device_main.py... -python _device_main.py +echo Starting laser_control.gui.main... +python -m laser_control.gui.main set "EXIT_CODE=%ERRORLEVEL%" popd >nul diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 6160309..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Shared fixtures for laser_control tests. -""" - -import pytest -import struct -from unittest.mock import MagicMock, patch -from laser_control.protocol import Protocol, _build_crc, _flipfour, _int_to_hex4 -from laser_control.controller import LaserController -from laser_control.conversions import ( - current_n_to_ma, temp_n_to_c, temp_ext_n_to_c, - voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v, -) - - -def make_valid_response( - current1_n: int = 10000, - current2_n: int = 12000, - temp1_n: int = 30000, - temp2_n: int = 32000, - temp_ext1_n: int = 2048, - temp_ext2_n: int = 2048, - mon_3v3_n: int = 2703, # ~3.3V - mon_5v1_n: int = 2731, # ~5.0V - mon_5v2_n: int = 2731, - mon_7v0_n: int = 1042, # ~7.0V - message_id: int = 12345, -) -> bytes: - """ - Build a syntactically valid 30-byte DATA response. - - Words (each 2 bytes, little-endian via flipfour): - 0 header - 1 I1 - 2 I2 - 3 TO6_LSB - 4 TO6_MSB - 5 Temp_1 - 6 Temp_2 - 7 Temp_Ext_1 - 8 Temp_Ext_2 - 9 MON_3V3 - 10 MON_5V1 - 11 MON_5V2 - 12 MON_7V0 - 13 Message_ID - 14 CRC - """ - words_raw = [ - 0xABCD, # Word 0 header - current1_n, # Word 1 - current2_n, # Word 2 - 0, # Word 3 TO6_LSB - 0, # Word 4 TO6_MSB - temp1_n, # Word 5 - temp2_n, # Word 6 - temp_ext1_n, # Word 7 - temp_ext2_n, # Word 8 - mon_3v3_n, # Word 9 - mon_5v1_n, # Word 10 - mon_5v2_n, # Word 11 - mon_7v0_n, # Word 12 - message_id, # Word 13 - 0, # Word 14 CRC placeholder - ] - - # Build hex string with flipfour applied - hex_str = "" - for w in words_raw: - hex_str += _flipfour(_int_to_hex4(w)) - - # Compute CRC over words 1..13 (indices 4..55 in hex, i.e. skip word 0) - words_hex = [hex_str[i:i+4] for i in range(0, len(hex_str), 4)] - crc_words = words_hex[1:14] # words 1..13 - crc_val = int(crc_words[0], 16) - for w in crc_words[1:]: - crc_val ^= int(w, 16) - - # Replace CRC word (stored in wire order, no flipfour) - hex_str = hex_str[:56] + _int_to_hex4(crc_val) - return bytes.fromhex(hex_str) - - -@pytest.fixture -def valid_response_bytes(): - """Pre-built valid 30-byte device response.""" - return make_valid_response() - - -@pytest.fixture -def mock_serial(): - """Mock serial.Serial object.""" - with patch('serial.Serial') as mock_cls: - mock_instance = MagicMock() - mock_instance.is_open = True - mock_cls.return_value = mock_instance - yield mock_instance - - -@pytest.fixture -def connected_controller(mock_serial): - """LaserController with mocked serial connection.""" - mock_serial.read.return_value = make_valid_response() - - ctrl = LaserController(port='/dev/ttyUSB0') - with patch('serial.Serial', return_value=mock_serial): - ctrl._protocol._serial = mock_serial - mock_serial.is_open = True - return ctrl \ No newline at end of file diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index a0b1af0..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,298 +0,0 @@ -""" -Integration tests for the laser control module. - -Tests the full call chain: LaserController → Protocol → Serial, -using mock serial ports. No real hardware required. -""" - -import pytest -import time -from unittest.mock import MagicMock, patch, call -from laser_control.controller import LaserController -from laser_control.models import VariationType, DeviceState -from laser_control.exceptions import ( - ValidationError, - CommunicationError, - TemperatureOutOfRangeError, - CurrentOutOfRangeError, -) -from laser_control.protocol import Protocol, CommandCode -from .conftest import make_valid_response - - -class TestManualModeIntegration: - """Integration tests for manual mode operation.""" - - def test_full_manual_mode_flow(self, connected_controller, mock_serial): - """Test complete manual mode command flow.""" - connected_controller.set_manual_mode( - temp1=25.0, temp2=30.0, - current1=40.0, current2=35.0 - ) - - # Verify command was sent - assert mock_serial.write.called - sent_data = mock_serial.write.call_args[0][0] - assert len(sent_data) == 30 # SEND_PARAMS_TOTAL_LENGTH - - # Verify command code (bytes 0-1, little-endian 0x1111 → 0x11 0x11) - assert sent_data[0] == 0x11 - assert sent_data[1] == 0x11 - - def test_manual_mode_validation_rejects_invalid_temp(self, connected_controller): - """Test that manual mode rejects out-of-range temperature.""" - with pytest.raises(TemperatureOutOfRangeError) as exc_info: - connected_controller.set_manual_mode( - temp1=50.0, # Too high - temp2=30.0, - current1=40.0, - current2=35.0 - ) - assert "temp1" in str(exc_info.value) - assert "50.0" in str(exc_info.value) - - def test_manual_mode_validation_rejects_invalid_current(self, connected_controller): - """Test that manual mode rejects out-of-range current.""" - with pytest.raises(CurrentOutOfRangeError) as exc_info: - connected_controller.set_manual_mode( - temp1=25.0, - temp2=30.0, - current1=40.0, - current2=70.0 # Too high - ) - assert "current2" in str(exc_info.value) - - def test_manual_mode_no_serial_call_on_validation_failure( - self, connected_controller, mock_serial - ): - """Serial write must not be called when validation fails.""" - mock_serial.write.reset_mock() - with pytest.raises(ValidationError): - connected_controller.set_manual_mode( - temp1=5.0, # Invalid - temp2=30.0, - current1=40.0, - current2=35.0 - ) - mock_serial.write.assert_not_called() - - def test_message_id_increments(self, connected_controller, mock_serial): - """Message ID should increment with each command.""" - initial_id = connected_controller._message_id - connected_controller.set_manual_mode(25.0, 30.0, 40.0, 35.0) - assert connected_controller._message_id == (initial_id + 1) & 0xFFFF - - connected_controller.set_manual_mode(26.0, 31.0, 41.0, 36.0) - assert connected_controller._message_id == (initial_id + 2) & 0xFFFF - - -class TestVariationModeIntegration: - """Integration tests for variation mode operation.""" - - def test_current_ld1_variation_flow(self, connected_controller, mock_serial): - """Test complete current variation for laser 1.""" - params = { - 'min_value': 20.0, - 'max_value': 50.0, - 'step': 0.5, - 'time_step': 50, - 'delay_time': 5, - 'static_temp1': 25.0, - 'static_temp2': 30.0, - 'static_current1': 35.0, - 'static_current2': 35.0, - } - connected_controller.start_variation(VariationType.CHANGE_CURRENT_LD1, params) - - assert mock_serial.write.called - sent_data = mock_serial.write.call_args[0][0] - assert len(sent_data) == 32 # TASK_ENABLE_COMMAND_LENGTH - - # Verify command code (0x7777) - assert sent_data[0] == 0x77 - assert sent_data[1] == 0x77 - - def test_current_ld2_variation_flow(self, connected_controller, mock_serial): - """Test complete current variation for laser 2.""" - params = { - 'min_value': 20.0, - 'max_value': 50.0, - 'step': 0.5, - 'time_step': 50, - 'delay_time': 5, - 'static_temp1': 25.0, - 'static_temp2': 30.0, - 'static_current1': 35.0, - 'static_current2': 35.0, - } - connected_controller.start_variation(VariationType.CHANGE_CURRENT_LD2, params) - assert mock_serial.write.called - - def test_variation_rejects_invalid_step(self, connected_controller, mock_serial): - """Variation must reject step below minimum.""" - mock_serial.write.reset_mock() - params = { - 'min_value': 20.0, - 'max_value': 50.0, - 'step': 0.001, # Too small - 'time_step': 50, - 'delay_time': 5, - 'static_temp1': 25.0, - 'static_temp2': 30.0, - 'static_current1': 35.0, - 'static_current2': 35.0, - } - with pytest.raises(ValidationError): - connected_controller.start_variation(VariationType.CHANGE_CURRENT_LD1, params) - mock_serial.write.assert_not_called() - - def test_variation_rejects_inverted_range(self, connected_controller): - """Variation must reject min > max.""" - params = { - 'min_value': 50.0, # min > max - 'max_value': 20.0, - 'step': 0.5, - 'time_step': 50, - 'delay_time': 5, - 'static_temp1': 25.0, - 'static_temp2': 30.0, - 'static_current1': 35.0, - 'static_current2': 35.0, - } - with pytest.raises(ValidationError) as exc_info: - connected_controller.start_variation(VariationType.CHANGE_CURRENT_LD1, params) - assert "min" in str(exc_info.value).lower() - - -class TestMeasurementsIntegration: - """Integration tests for measurement retrieval.""" - - def test_get_measurements_returns_data(self, connected_controller, mock_serial): - """get_measurements should decode and return device data.""" - mock_serial.read.return_value = make_valid_response() - measurements = connected_controller.get_measurements() - - assert measurements is not None - assert isinstance(measurements.current1, float) - assert isinstance(measurements.current2, float) - assert isinstance(measurements.temp1, float) - assert isinstance(measurements.temp2, float) - assert isinstance(measurements.voltage_3v3, float) - - def test_get_measurements_calls_callback(self, mock_serial): - """on_data callback should be triggered on new measurements.""" - received = [] - mock_serial.read.return_value = make_valid_response() - mock_serial.is_open = True - - ctrl = LaserController( - port='/dev/ttyUSB0', - on_data=lambda m: received.append(m) - ) - ctrl._protocol._serial = mock_serial - - ctrl.get_measurements() - assert len(received) == 1 - assert received[0].voltage_3v3 > 0 - - def test_get_measurements_no_data(self, connected_controller, mock_serial): - """get_measurements returns None when no data received.""" - mock_serial.read.return_value = b'' - result = connected_controller.get_measurements() - assert result is None - - def test_voltage_rail_check(self, connected_controller, mock_serial): - """Test power rail health check on measurements.""" - mock_serial.read.return_value = make_valid_response( - mon_3v3_n=2703, # ~3.3V - mon_5v1_n=2731, # ~5.0V - mon_5v2_n=2731, - mon_7v0_n=1042, # ~7.0V - ) - measurements = connected_controller.get_measurements() - if measurements: - rails = measurements.check_power_rails() - assert isinstance(rails, dict) - assert '3v3' in rails - assert '5v1' in rails - assert '5v2' in rails - assert '7v0' in rails - - -class TestConnectionManagement: - """Integration tests for connection handling.""" - - def test_context_manager(self, mock_serial): - """Test using LaserController as context manager.""" - mock_serial.is_open = True - with patch('serial.Serial', return_value=mock_serial): - with LaserController(port='/dev/ttyUSB0') as ctrl: - assert ctrl.is_connected - mock_serial.close.assert_called() - - def test_send_without_connection_raises(self): - """Sending command without connection raises CommunicationError.""" - ctrl = LaserController(port='/dev/ttyUSB0') - # Don't call connect() - with pytest.raises(CommunicationError) as exc_info: - ctrl.set_manual_mode(25.0, 30.0, 40.0, 35.0) - assert "connect" in str(exc_info.value).lower() - - def test_stop_task_sends_default_enable(self, connected_controller, mock_serial): - """stop_task should send DEFAULT_ENABLE (0x2222) first, then DECODE_ENABLE (0x1111).""" - mock_serial.write.reset_mock() - connected_controller.stop_task() - - assert mock_serial.write.call_count >= 2 - # First call: DEFAULT_ENABLE 0x2222 → flipped bytes 0x22 0x22 - first_call = mock_serial.write.call_args_list[0][0][0] - assert first_call[0] == 0x22 - assert first_call[1] == 0x22 - # Second call: DECODE_ENABLE 0x1111 → flipped bytes 0x11 0x11 - second_call = mock_serial.write.call_args_list[1][0][0] - assert second_call[0] == 0x11 - assert second_call[1] == 0x11 - - def test_reset_sends_default_enable(self, connected_controller, mock_serial): - """reset() should also send DEFAULT_ENABLE.""" - mock_serial.write.reset_mock() - connected_controller.reset() - assert mock_serial.write.called - - -class TestConversionsRoundtrip: - """Test that physical unit conversions are self-consistent.""" - - def test_temperature_roundtrip(self): - """temp_c_to_n and temp_n_to_c should be inverse of each other.""" - from laser_control.conversions import temp_c_to_n, temp_n_to_c - for temp in [15.0, 20.0, 25.0, 30.0, 35.0, 40.0]: - n = temp_c_to_n(temp) - recovered = temp_n_to_c(n) - assert abs(recovered - temp) < 0.05, \ - f"Temperature roundtrip failed for {temp}°C: got {recovered}°C" - - def test_current_roundtrip(self): - """current_ma_to_n and current_n_to_ma should be approximately inverse.""" - from laser_control.conversions import current_ma_to_n, current_n_to_ma - # Note: current_n_to_ma is for photodiode readback, not exact inverse - # of current_ma_to_n (different calibration paths). - # We just test that values are in plausible range. - for current in [15.0, 30.0, 45.0, 60.0]: - n = current_ma_to_n(current) - assert 0 <= n <= 65535 - - def test_voltage_conversions_nominal(self): - """Test voltage conversions at nominal counts.""" - from laser_control.conversions import ( - voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v - ) - # Approximate nominal ADC counts for each rail - v33 = voltage_3v3_n_to_v(2703) - assert 3.1 <= v33 <= 3.5, f"3.3V rail: {v33}" - - v5 = voltage_5v_n_to_v(2731) - assert 4.8 <= v5 <= 5.3, f"5V rail: {v5}" - - v7 = voltage_7v_n_to_v(1042) - assert 6.5 <= v7 <= 7.5, f"7V rail: {v7}" \ No newline at end of file diff --git a/tests/test_protocol.py b/tests/test_protocol.py deleted file mode 100644 index 1362fec..0000000 --- a/tests/test_protocol.py +++ /dev/null @@ -1,345 +0,0 @@ -""" -Tests for communication protocol module. - -Testing command encoding/decoding, CRC calculations, -and protocol message structure. -""" - -import pytest -from unittest.mock import Mock, MagicMock, patch, call -import struct -from laser_control.protocol import ( - Protocol, - CommandCode, - TaskType, - Message, - Response -) -from laser_control.exceptions import ( - CommunicationError, - CRCError, - ProtocolError -) - - -class TestCRCCalculation: - """Test CRC calculation and verification.""" - - def test_crc_calculation(self): - """Test CRC calculation for known data (at least 2 words needed).""" - # calculate_crc skips word 0 and XORs words 1..N - # So we need at least 4 bytes (2 words) - data = b'\x00\x01\x02\x03\x04\x05\x06\x07' - crc = Protocol.calculate_crc(data) - assert isinstance(crc, int) - assert 0 <= crc <= 0xFFFF - - def test_crc_consistency(self): - """Test CRC calculation consistency.""" - data = b'\x11\x11' + b'\x00' * 26 + b'\xFF\xFF' # 30 bytes - crc1 = Protocol.calculate_crc(data) - crc2 = Protocol.calculate_crc(data) - assert crc1 == crc2 - - def test_crc_different_data(self): - """Test CRC differs for different data.""" - data1 = b'\x00\x00\x01\x02\x03\x04' - data2 = b'\x00\x00\x05\x06\x07\x08' - crc1 = Protocol.calculate_crc(data1) - crc2 = Protocol.calculate_crc(data2) - assert crc1 != crc2 - - -class TestMessageEncoding: - """Test message encoding for device commands.""" - - def test_encode_decode_enable_command(self): - """Test encoding DECODE_ENABLE command.""" - message = Protocol.encode_decode_enable( - temp1=25.5, - temp2=30.0, - current1=40.0, - current2=35.0, - pi_coeff1_p=1, - pi_coeff1_i=1, - pi_coeff2_p=1, - pi_coeff2_i=1, - message_id=12345 - ) - - assert isinstance(message, bytes) - assert len(message) == 30 # Expected message length - - # Check command code (0x1111 stored little-endian via flipfour → 0x11 0x11) - assert message[0] == 0x11 - assert message[1] == 0x11 - - def test_encode_task_enable_command(self): - """Test encoding TASK_ENABLE command.""" - message = Protocol.encode_task_enable( - task_type=TaskType.CHANGE_CURRENT_LD1, - static_temp1=25.0, - static_temp2=30.0, - static_current1=40.0, - static_current2=35.0, - min_value=20.0, - max_value=50.0, - step=0.5, - time_step=50, - delay_time=5, - message_id=54321 - ) - - assert isinstance(message, bytes) - assert len(message) > 0 - - # Check command code - command = struct.unpack(' 0xFFFF should wrap (& 0xFFFF in controller) - large_id = 0x10000 + 123 - wrapped = large_id & 0xFFFF - message = Protocol.encode_decode_enable( - temp1=25.0, temp2=30.0, - current1=40.0, current2=35.0, - pi_coeff1_p=1, pi_coeff1_i=1, - pi_coeff2_p=1, pi_coeff2_i=1, - message_id=wrapped, - ) - assert isinstance(message, bytes) - assert len(message) == 30 - - def test_negative_values_handling(self): - """Test handling of negative values where not allowed.""" - with pytest.raises(ValueError): - Protocol.encode_decode_enable( - temp1=25.0, - temp2=30.0, - current1=-10.0, # Negative current - current2=35.0, - pi_coeff1_p=1.0, - pi_coeff1_i=0.5, - pi_coeff2_p=1.0, - pi_coeff2_i=0.5, - message_id=12345 - ) \ No newline at end of file diff --git a/tests/test_validation.py b/tests/test_validation.py deleted file mode 100644 index d1b5c29..0000000 --- a/tests/test_validation.py +++ /dev/null @@ -1,383 +0,0 @@ -""" -Tests for parameter validation module. - -Testing validation of all input parameters with boundary conditions, -invalid types, and edge cases. -""" - -import pytest -import math -from laser_control.validators import ParameterValidator -from laser_control.exceptions import ( - ValidationError, - TemperatureOutOfRangeError, - CurrentOutOfRangeError, - InvalidParameterError -) -from laser_control.models import VariationType - - -class TestTemperatureValidation: - """Test temperature parameter validation.""" - - def test_valid_temperature_range(self): - """Test temperatures within valid range.""" - # Valid temperatures should pass - assert ParameterValidator.validate_temperature(15.0, "temp1") == 15.0 - assert ParameterValidator.validate_temperature(25.5, "temp2") == 25.5 - assert ParameterValidator.validate_temperature(40.0, "temp1") == 40.0 - - def test_temperature_below_minimum(self): - """Test temperature below minimum threshold.""" - with pytest.raises(TemperatureOutOfRangeError) as exc_info: - ParameterValidator.validate_temperature(10.0, "temp1") - assert "temp1" in str(exc_info.value) - assert "15.0" in str(exc_info.value) # min value - - def test_temperature_above_maximum(self): - """Test temperature above maximum threshold.""" - with pytest.raises(TemperatureOutOfRangeError) as exc_info: - ParameterValidator.validate_temperature(45.0, "temp2") - assert "temp2" in str(exc_info.value) - assert "40.0" in str(exc_info.value) # max value - - def test_temperature_invalid_type(self): - """Test invalid temperature type.""" - with pytest.raises(InvalidParameterError) as exc_info: - ParameterValidator.validate_temperature("invalid", "temp1") - assert "temp1" in str(exc_info.value) - assert "number" in str(exc_info.value).lower() - - def test_temperature_nan_value(self): - """Test NaN temperature value.""" - with pytest.raises(InvalidParameterError) as exc_info: - ParameterValidator.validate_temperature(float('nan'), "temp1") - assert "NaN" in str(exc_info.value) - - def test_temperature_inf_value(self): - """Test infinite temperature value.""" - with pytest.raises(InvalidParameterError) as exc_info: - ParameterValidator.validate_temperature(float('inf'), "temp2") - assert "infinite" in str(exc_info.value).lower() - - def test_temperature_none_value(self): - """Test None temperature value.""" - with pytest.raises(InvalidParameterError) as exc_info: - ParameterValidator.validate_temperature(None, "temp1") - assert "temp1" in str(exc_info.value) - - -class TestCurrentValidation: - """Test current parameter validation.""" - - def test_valid_current_range(self): - """Test currents within valid range.""" - assert ParameterValidator.validate_current(15.0, "current1") == 15.0 - assert ParameterValidator.validate_current(37.5, "current2") == 37.5 - assert ParameterValidator.validate_current(60.0, "current1") == 60.0 - - def test_current_below_minimum(self): - """Test current below minimum threshold.""" - with pytest.raises(CurrentOutOfRangeError) as exc_info: - ParameterValidator.validate_current(10.0, "current1") - assert "current1" in str(exc_info.value) - assert "15.0" in str(exc_info.value) # min value - - def test_current_above_maximum(self): - """Test current above maximum threshold.""" - with pytest.raises(CurrentOutOfRangeError) as exc_info: - ParameterValidator.validate_current(65.0, "current2") - assert "current2" in str(exc_info.value) - assert "60.0" in str(exc_info.value) # max value - - def test_current_invalid_type(self): - """Test invalid current type.""" - with pytest.raises(InvalidParameterError) as exc_info: - ParameterValidator.validate_current([15, 20], "current1") - assert "current1" in str(exc_info.value) - - def test_current_negative_value(self): - """Test negative current value.""" - with pytest.raises(CurrentOutOfRangeError) as exc_info: - ParameterValidator.validate_current(-5.0, "current1") - assert "current1" in str(exc_info.value) - - -class TestVariationParameterValidation: - """Test variation mode parameter validation.""" - - def test_valid_current_variation_params(self): - """Test valid parameters for current variation.""" - params = { - 'min_value': 20.0, - 'max_value': 50.0, - 'step': 0.5, - 'time_step': 50, # microseconds - 'delay_time': 5 # milliseconds - } - validated = ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_CURRENT_LD1 - ) - assert validated['min_value'] == 20.0 - assert validated['max_value'] == 50.0 - assert validated['step'] == 0.5 - - def test_variation_min_greater_than_max(self): - """Test min value greater than max value.""" - params = { - 'min_value': 50.0, - 'max_value': 20.0, - 'step': 0.5, - 'time_step': 50, - 'delay_time': 5 - } - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_CURRENT_LD1 - ) - assert "min" in str(exc_info.value).lower() - assert "max" in str(exc_info.value).lower() - - def test_variation_invalid_step(self): - """Test invalid step values.""" - # Zero step - params = { - 'min_value': 20.0, - 'max_value': 50.0, - 'step': 0, - 'time_step': 50, - 'delay_time': 5 - } - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_CURRENT_LD1 - ) - assert "step" in str(exc_info.value).lower() - - # Negative step - params['step'] = -0.5 - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_CURRENT_LD1 - ) - assert "step" in str(exc_info.value).lower() - - def test_variation_step_too_small(self): - """Test step value too small for current.""" - params = { - 'min_value': 20.0, - 'max_value': 50.0, - 'step': 0.001, # Too small for current (min 0.002) - 'time_step': 50, - 'delay_time': 5 - } - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_CURRENT_LD2 - ) - assert "step" in str(exc_info.value).lower() - assert "0.002" in str(exc_info.value) - - def test_variation_step_too_large(self): - """Test step value too large.""" - params = { - 'min_value': 20.0, - 'max_value': 50.0, - 'step': 10.0, # Too large for current (max 0.5) - 'time_step': 50, - 'delay_time': 5 - } - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_CURRENT_LD1 - ) - assert "step" in str(exc_info.value).lower() - assert "0.5" in str(exc_info.value) - - def test_valid_temperature_variation_params(self): - """Test valid parameters for temperature variation.""" - params = { - 'min_value': 20.0, - 'max_value': 35.0, - 'step': 0.1, - 'time_step': 50, - 'delay_time': 5 - } - validated = ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_TEMPERATURE_LD1 - ) - assert validated['min_value'] == 20.0 - assert validated['max_value'] == 35.0 - assert validated['step'] == 0.1 - - def test_temperature_variation_step_bounds(self): - """Test temperature variation step boundaries.""" - params = { - 'min_value': 20.0, - 'max_value': 35.0, - 'step': 0.02, # Too small (min 0.05) - 'time_step': 50, - 'delay_time': 5 - } - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_TEMPERATURE_LD2 - ) - assert "0.05" in str(exc_info.value) - - params['step'] = 2.0 # Too large (max 1.0) - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_TEMPERATURE_LD1 - ) - assert "1.0" in str(exc_info.value) - - def test_missing_required_params(self): - """Test missing required parameters.""" - params = { - 'min_value': 20.0, - 'max_value': 50.0 - # Missing step, time_step, delay_time - } - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - VariationType.CHANGE_CURRENT_LD1 - ) - assert "required" in str(exc_info.value).lower() - - def test_invalid_variation_type(self): - """Test invalid variation type.""" - params = { - 'min_value': 20.0, - 'max_value': 50.0, - 'step': 0.5, - 'time_step': 50, - 'delay_time': 5 - } - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_variation_params( - params, - "INVALID_TYPE" - ) - assert "variation type" in str(exc_info.value).lower() - - -class TestTimeParameterValidation: - """Test time parameter validation.""" - - def test_valid_time_params(self): - """Test valid time parameters.""" - step_time, delay_time = ParameterValidator.validate_time_params(50, 5) - assert step_time == 50 - assert delay_time == 5 - - step_time, delay_time = ParameterValidator.validate_time_params(20, 3) - assert step_time == 20 - assert delay_time == 3 - - step_time, delay_time = ParameterValidator.validate_time_params(100, 10) - assert step_time == 100 - assert delay_time == 10 - - def test_time_step_below_minimum(self): - """Test time step below minimum.""" - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_time_params(10, 5) # Min is 20 - assert "time step" in str(exc_info.value).lower() - assert "20" in str(exc_info.value) - - def test_time_step_above_maximum(self): - """Test time step above maximum.""" - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_time_params(150, 5) # Max is 100 - assert "time step" in str(exc_info.value).lower() - assert "100" in str(exc_info.value) - - def test_delay_time_below_minimum(self): - """Test delay time below minimum.""" - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_time_params(50, 1) # Min is 3 - assert "delay" in str(exc_info.value).lower() - assert "3" in str(exc_info.value) - - def test_delay_time_above_maximum(self): - """Test delay time above maximum.""" - with pytest.raises(ValidationError) as exc_info: - ParameterValidator.validate_time_params(50, 15) # Max is 10 - assert "delay" in str(exc_info.value).lower() - assert "10" in str(exc_info.value) - - def test_time_params_invalid_type(self): - """Test invalid type for time parameters.""" - with pytest.raises(InvalidParameterError): - ParameterValidator.validate_time_params("50", 5) - - with pytest.raises(InvalidParameterError): - ParameterValidator.validate_time_params(50, [5]) - - def test_time_params_float_conversion(self): - """Test float to int conversion for time parameters.""" - step_time, delay_time = ParameterValidator.validate_time_params(50.7, 5.2) - assert step_time == 50 # Should be truncated to int - assert delay_time == 5 - - -class TestManualModeValidation: - """Test manual mode parameter validation.""" - - def test_validate_all_manual_params(self): - """Test validation of all manual mode parameters at once.""" - result = ParameterValidator.validate_manual_mode_params( - temp1=25.0, - temp2=30.0, - current1=40.0, - current2=35.0 - ) - assert result['temp1'] == 25.0 - assert result['temp2'] == 30.0 - assert result['current1'] == 40.0 - assert result['current2'] == 35.0 - - def test_manual_mode_invalid_combination(self): - """Test invalid parameter combinations in manual mode.""" - # One invalid parameter should fail all validation - with pytest.raises(ValidationError): - ParameterValidator.validate_manual_mode_params( - temp1=25.0, - temp2=30.0, - current1=70.0, # Too high - current2=35.0 - ) - - def test_manual_mode_boundary_values(self): - """Test boundary values for manual mode.""" - # All minimum values - result = ParameterValidator.validate_manual_mode_params( - temp1=15.0, - temp2=15.0, - current1=15.0, - current2=15.0 - ) - assert all(v in [15.0] for v in result.values()) - - # All maximum values - result = ParameterValidator.validate_manual_mode_params( - temp1=40.0, - temp2=40.0, - current1=60.0, - current2=60.0 - ) - assert result['temp1'] == 40.0 - assert result['temp2'] == 40.0 - assert result['current1'] == 60.0 - assert result['current2'] == 60.0 \ No newline at end of file