Files
Uni-Lab-OS/unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
Xuwznln 9aeffebde1 0.10.7 Update (#101)
* Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>

* fix: workstation handlers and vessel_id parsing

* fix: working dir error when input config path
feat: report publish topic when error

* modify default discovery_interval to 15s

* feat: add trace log level

* feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79)

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

* feat: 优化protocol node节点运行日志

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

* refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

* update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84)

* Add:msgs.action

* update: 将版本号从 0.10.3 更新为 0.10.4

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

* fix: station自己的方法注册错误

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

* 修复工站的tracker实例追踪失效问题

* 增加handle检测,增加material edge关系上传

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

* 主机节点信息等支持自动刷新

* 注册表编辑器

* 修复status密集发送时,消息出错

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

- Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes.
- Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume.
- Both actions include response fields for return information and success status.

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

* Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

* 对于PRCXI9320的transfer_group,一对多和多对多

* 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

* Fix: run-column with correct vessel id (#86)

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

* 重新规定了版位推荐的入参

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

* 修复移液站错误的aspirate注册表

* Feature/xprbalance-zhida (#80)

* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* frontend_docs

* create/update resources with POST/PUT for big amount/ small amount data

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

* Workstation templates: Resources and its CRUD, and workstation tasks (#95)

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

* Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities

- Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability.
- Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input.
- Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input.
- Refined `create_diamine_solution_task` to include additional parameters for better task configuration.
- Enhanced schema descriptions and default values for improved user guidance.

* 修复to_plr_resources

* add update remove

* 支持选择器注册表自动生成
支持转运物料

* 修复资源添加

* 修复transfer_resource_to_another生成

* 更新transfer_resource_to_another参数,支持spot入参

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

* 修复同步任务报错不显示的bug

* feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了

* update todo

* modify bioyond/plr converter, bioyond resource registry, and tests

* pass the tests

* update todo

* add conda-pack-build.yml

* add auto install script for conda-pack-build.yml

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* Add version in __init__.py
Update conda-pack-build.yml
Add create_zip_archive.py

* Update conda-pack-build.yml

* Update conda-pack-build.yml (with mamba)

* Update conda-pack-build.yml

* Fix FileNotFoundError

* Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined>

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

* Update registry. Update uuid loop figure method. Update install docs.

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included.

* feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档

refactor: 重新组织LaiYu_Liquid模块目录结构
docs: 添加SOPA移液器和步进电机控制指令文档
fix: 修正设备配置中的最大体积默认值
test: 新增工作台配置测试用例
chore: 删除过时的测试脚本和配置文件

* add

* 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用

- 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py
- 更新所有相关文件中的导入引用
- 保持代码功能不变,仅改善命名一致性
- 测试确认所有导入正常工作

* 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出

- 添加 LaiYuLiquidBackend 到导入列表
- 添加 LaiYuLiquidBackend 到 __all__ 导出列表
- 确保所有主要类都可以正确导入

* 修复大小写文件夹名字

* 电池装配工站二次开发教程(带目录)上传至dev (#94)

* 电池装配工站二次开发教程

* Update intro.md

* 物料教程

* 更新物料教程,json格式注释

* Update prcxi driver & fix transfer_liquid mix_times (#90)

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
2025-10-12 23:34:26 +08:00

1080 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SOPA气动式移液器RS485控制驱动程序
基于SOPA气动式移液器RS485控制指令合集编写的Python驱动程序
支持完整的移液器控制功能,包括移液、检测、配置等操作。
仅支持SC-STxxx-00-13型号的RS485通信。
"""
import serial
import time
import logging
import threading
from typing import Optional, Union, Dict, Any, Tuple, List
from enum import Enum, IntEnum
from dataclasses import dataclass
from contextlib import contextmanager
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SOPAError(Exception):
"""SOPA移液器异常基类"""
pass
class SOPACommunicationError(SOPAError):
"""通信异常"""
pass
class SOPADeviceError(SOPAError):
"""设备异常"""
pass
class SOPAStatusCode(IntEnum):
"""状态码枚举"""
NO_ERROR = 0x00 # 无错误
ACTION_INCOMPLETE = 0x01 # 上次动作未完成
NOT_INITIALIZED = 0x02 # 设备未初始化
DEVICE_OVERLOAD = 0x03 # 设备过载
INVALID_COMMAND = 0x04 # 无效指令
LLD_FAULT = 0x05 # 液位探测故障
AIR_ASPIRATE = 0x0D # 空吸
NEEDLE_BLOCK = 0x0E # 堵针
FOAM_DETECT = 0x10 # 泡沫
EXCEED_TIP_VOLUME = 0x11 # 吸液超过吸头容量
class CommunicationType(Enum):
"""通信类型"""
TERMINAL_DEBUG = "/" # 终端调试头码为0x2F
OEM_COMMUNICATION = "[" # OEM通信头码为0x5B
class DetectionMode(IntEnum):
"""液位检测模式"""
PRESSURE = 0 # 压力式检测(pLLD)
CAPACITIVE = 1 # 电容式检测(cLLD)
@dataclass
class SOPAConfig:
"""SOPA移液器配置参数"""
# 通信参数
port: str = "/dev/ttyUSB0"
baudrate: int = 115200
address: int = 1
timeout: float = 5.0
comm_type: CommunicationType = CommunicationType.TERMINAL_DEBUG
# 运动参数 (单位: 0.1ul/秒)
max_speed: int = 2000 # 最高速度 200ul/秒
start_speed: int = 200 # 启动速度 20ul/秒
cutoff_speed: int = 200 # 断流速度 20ul/秒
acceleration: int = 30000 # 加速度
# 检测参数
empty_threshold: int = 4 # 空吸门限
foam_threshold: int = 20 # 泡沫门限
block_threshold: int = 350 # 堵塞门限
# 液位检测参数
lld_speed: int = 200 # 检测速度 (100~2000)
lld_sensitivity: int = 5 # 检测灵敏度 (3~40)
detection_mode: DetectionMode = DetectionMode.PRESSURE
# 吸头参数
tip_volume: int = 1000 # 吸头容量 (ul)
calibration_factor: float = 1.0 # 校准系数
compensation_offset: float = 0.0 # 补偿偏差
def __post_init__(self):
"""初始化后验证参数"""
self._validate_address()
def _validate_address(self):
"""
验证设备地址是否符合协议要求
协议要求:
- 地址范围1~254
- 禁用地址47, 69, 91 (对应ASCII字符 '/', 'E', '[')
"""
if not (1 <= self.address <= 254):
raise ValueError(f"设备地址必须在1-254范围内当前地址: {self.address}")
forbidden_addresses = [47, 69, 91] # '/', 'E', '['
if self.address in forbidden_addresses:
forbidden_chars = {47: "'/' (0x2F)", 69: "'E' (0x45)", 91: "'[' (0x5B)"}
char_desc = forbidden_chars[self.address]
raise ValueError(
f"地址 {self.address} 不可用,因为它对应协议字符 {char_desc}"
f"请选择其他地址1-254排除47、69、91"
)
class SOPAPipette:
"""SOPA气动式移液器驱动类"""
def __init__(self, config: SOPAConfig):
"""
初始化SOPA移液器
Args:
config: 移液器配置参数
"""
self.config = config
self.serial_port: Optional[serial.Serial] = None
self.is_connected = False
self.is_initialized = False
self.lock = threading.Lock()
# 状态缓存
self._last_status = SOPAStatusCode.NOT_INITIALIZED
self._current_position = 0
self._tip_present = False
def connect(self) -> bool:
"""
连接移液器
Returns:
bool: 连接是否成功
"""
try:
self.serial_port = serial.Serial(
port=self.config.port,
baudrate=self.config.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.config.timeout
)
if self.serial_port.is_open:
self.is_connected = True
logger.info(f"已连接到SOPA移液器端口: {self.config.port}, 地址: {self.config.address}")
# 查询设备信息
version = self.get_firmware_version()
if version:
logger.info(f"固件版本: {version}")
return True
else:
raise SOPACommunicationError("串口打开失败")
except Exception as e:
logger.error(f"连接失败: {str(e)}")
self.is_connected = False
return False
def disconnect(self):
"""断开连接"""
if self.serial_port and self.serial_port.is_open:
self.serial_port.close()
self.is_connected = False
self.is_initialized = False
logger.info("已断开SOPA移液器连接")
def _calculate_checksum(self, data: bytes) -> int:
"""计算校验和"""
return sum(data) & 0xFF
def _build_command(self, command: str) -> bytes:
"""
构建完整命令字节串
根据协议格式:头码 + 地址 + 命令/数据 + 尾码 + 校验和
Args:
command: 命令字符串
Returns:
bytes: 完整的命令字节串
"""
header = self.config.comm_type.value # '/' 或 '['
address = str(self.config.address) # 设备地址
tail = "E" # 尾码固定为 'E'
# 构建基础命令字符串:头码 + 地址 + 命令 + 尾码
cmd_str = f"{header}{address}{command}{tail}"
# 转换为字节串
cmd_bytes = cmd_str.encode('ascii')
# 计算校验和(所有字节的累加值)
checksum = self._calculate_checksum(cmd_bytes)
# 返回完整命令:基础命令字节 + 校验和字节
return cmd_bytes + bytes([checksum])
def _send_command(self, command: str) -> bool:
"""
发送命令到移液器
Args:
command: 要发送的命令
Returns:
bool: 命令是否发送成功
"""
if not self.is_connected or not self.serial_port:
raise SOPACommunicationError("设备未连接")
with self.lock:
try:
full_command_bytes = self._build_command(command)
# 转换为可读字符串用于日志显示
readable_cmd = ''.join(chr(b) if 32 <= b <= 126 else f'\\x{b:02X}' for b in full_command_bytes)
logger.debug(f"发送命令: {readable_cmd}")
self.serial_port.write(full_command_bytes)
self.serial_port.flush()
# 等待响应
time.sleep(0.1)
return True
except Exception as e:
logger.error(f"发送命令失败: {str(e)}")
raise SOPACommunicationError(f"发送命令失败: {str(e)}")
def _read_response(self, timeout: float = None) -> Optional[str]:
"""
读取设备响应
Args:
timeout: 超时时间
Returns:
Optional[str]: 设备响应字符串
"""
if not self.is_connected or not self.serial_port:
return None
timeout = timeout or self.config.timeout
try:
# 设置读取超时
self.serial_port.timeout = timeout
response = b''
start_time = time.time()
while time.time() - start_time < timeout:
if self.serial_port.in_waiting > 0:
chunk = self.serial_port.read(self.serial_port.in_waiting)
response += chunk
# 检查是否收到完整响应(以'E'结尾)
if response.endswith(b'E') or len(response) >= 20:
break
time.sleep(0.01)
if response:
decoded_response = response.decode('ascii', errors='ignore')
logger.debug(f"收到响应: {decoded_response}")
return decoded_response
except Exception as e:
logger.error(f"读取响应失败: {str(e)}")
return None
def _send_query(self, query: str) -> Optional[str]:
"""
发送查询命令并获取响应
Args:
query: 查询命令
Returns:
Optional[str]: 查询结果
"""
try:
self._send_command(query)
return self._read_response()
except Exception as e:
logger.error(f"查询失败: {str(e)}")
return None
# ==================== 基础控制方法 ====================
def initialize(self) -> bool:
"""
初始化移液器
Returns:
bool: 初始化是否成功
"""
try:
logger.info("初始化SOPA移液器...")
# 发送初始化命令
self._send_command("HE")
# 等待初始化完成
time.sleep(2.0)
# 检查状态
status = self.get_status()
if status == SOPAStatusCode.NO_ERROR:
self.is_initialized = True
logger.info("移液器初始化成功")
# 应用配置参数
self._apply_configuration()
return True
else:
logger.error(f"初始化失败,状态码: {status}")
return False
except Exception as e:
logger.error(f"初始化异常: {str(e)}")
return False
def _apply_configuration(self):
"""应用配置参数"""
try:
# 设置运动参数
self.set_acceleration(self.config.acceleration)
self.set_start_speed(self.config.start_speed)
self.set_cutoff_speed(self.config.cutoff_speed)
self.set_max_speed(self.config.max_speed)
# 设置检测参数
self.set_empty_threshold(self.config.empty_threshold)
self.set_foam_threshold(self.config.foam_threshold)
self.set_block_threshold(self.config.block_threshold)
# 设置吸头参数
self.set_tip_volume(self.config.tip_volume)
self.set_calibration_factor(self.config.calibration_factor)
# 设置液位检测参数
self.set_detection_mode(self.config.detection_mode)
self.set_lld_speed(self.config.lld_speed)
logger.info("配置参数应用完成")
except Exception as e:
logger.warning(f"应用配置参数失败: {str(e)}")
def eject_tip(self) -> bool:
"""
顶出枪头
Returns:
bool: 操作是否成功
"""
try:
logger.info("顶出枪头")
self._send_command("RE")
time.sleep(1.0)
return True
except Exception as e:
logger.error(f"顶出枪头失败: {str(e)}")
return False
def get_tip_status(self) -> bool:
"""
获取枪头状态
Returns:
bool: True表示有枪头False表示无枪头
"""
try:
response = self._send_query("Q28")
if response and len(response) > 10:
# 解析响应中的枪头状态
status_char = response[10] if len(response) > 10 else '0'
self._tip_present = (status_char == '1')
return self._tip_present
except Exception as e:
logger.error(f"获取枪头状态失败: {str(e)}")
return False
# ==================== 移液控制方法 ====================
def move_absolute(self, position: float) -> bool:
"""
绝对位置移动
Args:
position: 目标位置(微升)
Returns:
bool: 移动是否成功
"""
try:
if not self.is_initialized:
raise SOPADeviceError("设备未初始化")
pos_int = int(position)
logger.debug(f"绝对移动到位置: {pos_int}ul")
self._send_command(f"A{pos_int}E")
time.sleep(0.5)
self._current_position = pos_int
return True
except Exception as e:
logger.error(f"绝对移动失败: {str(e)}")
return False
def aspirate(self, volume: float, detection: bool = False) -> bool:
"""
抽吸液体
Args:
volume: 抽吸体积(微升)
detection: 是否开启液体检测
Returns:
bool: 抽吸是否成功
"""
try:
if not self.is_initialized:
raise SOPADeviceError("设备未初始化")
vol_int = int(volume)
logger.info(f"抽吸液体: {vol_int}ul, 检测: {detection}")
# 构建命令
cmd_parts = []
cmd_parts.append(f"a{self.config.acceleration}")
cmd_parts.append(f"b{self.config.start_speed}")
cmd_parts.append(f"c{self.config.cutoff_speed}")
cmd_parts.append(f"s{self.config.max_speed}")
if detection:
cmd_parts.append("f1") # 开启检测
cmd_parts.append(f"P{vol_int}")
if detection:
cmd_parts.append("f0") # 关闭检测
cmd_parts.append("E")
command = "".join(cmd_parts)
self._send_command(command)
# 等待操作完成
time.sleep(max(1.0, vol_int / 100.0))
# 检查状态
status = self.get_status()
if status == SOPAStatusCode.NO_ERROR:
self._current_position += vol_int
logger.info(f"抽吸成功: {vol_int}ul")
return True
elif status == SOPAStatusCode.AIR_ASPIRATE:
logger.warning("检测到空吸")
return False
elif status == SOPAStatusCode.NEEDLE_BLOCK:
logger.error("检测到堵针")
return False
else:
logger.error(f"抽吸失败,状态码: {status}")
return False
except Exception as e:
logger.error(f"抽吸失败: {str(e)}")
return False
def dispense(self, volume: float, detection: bool = False) -> bool:
"""
分配液体
Args:
volume: 分配体积(微升)
detection: 是否开启液体检测
Returns:
bool: 分配是否成功
"""
try:
if not self.is_initialized:
raise SOPADeviceError("设备未初始化")
vol_int = int(volume)
logger.info(f"分配液体: {vol_int}ul, 检测: {detection}")
# 构建命令
cmd_parts = []
cmd_parts.append(f"a{self.config.acceleration}")
cmd_parts.append(f"b{self.config.start_speed}")
cmd_parts.append(f"c{self.config.cutoff_speed}")
cmd_parts.append(f"s{self.config.max_speed}")
if detection:
cmd_parts.append("f1") # 开启检测
cmd_parts.append(f"D{vol_int}")
if detection:
cmd_parts.append("f0") # 关闭检测
cmd_parts.append("E")
command = "".join(cmd_parts)
self._send_command(command)
# 等待操作完成
time.sleep(max(1.0, vol_int / 200.0))
# 检查状态
status = self.get_status()
if status == SOPAStatusCode.NO_ERROR:
self._current_position -= vol_int
logger.info(f"分配成功: {vol_int}ul")
return True
else:
logger.error(f"分配失败,状态码: {status}")
return False
except Exception as e:
logger.error(f"分配失败: {str(e)}")
return False
# ==================== 液位检测方法 ====================
def liquid_level_detection(self, sensitivity: int = None) -> bool:
"""
执行液位检测
Args:
sensitivity: 检测灵敏度 (3~40)
Returns:
bool: 检测是否成功
"""
try:
if not self.is_initialized:
raise SOPADeviceError("设备未初始化")
sens = sensitivity or self.config.lld_sensitivity
if self.config.detection_mode == DetectionMode.PRESSURE:
# 压力式液面检测
command = f"m0k{self.config.lld_speed}L{sens}E"
else:
# 电容式液面检测
command = f"m1L{sens}E"
logger.info(f"执行液位检测, 模式: {self.config.detection_mode.name}, 灵敏度: {sens}")
self._send_command(command)
time.sleep(2.0)
# 检查检测结果
status = self.get_status()
if status == SOPAStatusCode.NO_ERROR:
logger.info("液位检测成功")
return True
elif status == SOPAStatusCode.LLD_FAULT:
logger.error("液位检测故障")
return False
else:
logger.warning(f"液位检测异常,状态码: {status}")
return False
except Exception as e:
logger.error(f"液位检测失败: {str(e)}")
return False
# ==================== 参数设置方法 ====================
def set_max_speed(self, speed: int) -> bool:
"""设置最高速度 (0.1ul/秒为单位)"""
try:
self._send_command(f"s{speed}E")
self.config.max_speed = speed
logger.debug(f"设置最高速度: {speed} (0.1ul/秒)")
return True
except Exception as e:
logger.error(f"设置最高速度失败: {str(e)}")
return False
def set_start_speed(self, speed: int) -> bool:
"""设置启动速度 (0.1ul/秒为单位)"""
try:
self._send_command(f"b{speed}E")
self.config.start_speed = speed
logger.debug(f"设置启动速度: {speed} (0.1ul/秒)")
return True
except Exception as e:
logger.error(f"设置启动速度失败: {str(e)}")
return False
def set_cutoff_speed(self, speed: int) -> bool:
"""设置断流速度 (0.1ul/秒为单位)"""
try:
self._send_command(f"c{speed}E")
self.config.cutoff_speed = speed
logger.debug(f"设置断流速度: {speed} (0.1ul/秒)")
return True
except Exception as e:
logger.error(f"设置断流速度失败: {str(e)}")
return False
def set_acceleration(self, accel: int) -> bool:
"""设置加速度"""
try:
self._send_command(f"a{accel}E")
self.config.acceleration = accel
logger.debug(f"设置加速度: {accel}")
return True
except Exception as e:
logger.error(f"设置加速度失败: {str(e)}")
return False
def set_empty_threshold(self, threshold: int) -> bool:
"""设置空吸门限"""
try:
self._send_command(f"${threshold}E")
self.config.empty_threshold = threshold
logger.debug(f"设置空吸门限: {threshold}")
return True
except Exception as e:
logger.error(f"设置空吸门限失败: {str(e)}")
return False
def set_foam_threshold(self, threshold: int) -> bool:
"""设置泡沫门限"""
try:
self._send_command(f"!{threshold}E")
self.config.foam_threshold = threshold
logger.debug(f"设置泡沫门限: {threshold}")
return True
except Exception as e:
logger.error(f"设置泡沫门限失败: {str(e)}")
return False
def set_block_threshold(self, threshold: int) -> bool:
"""设置堵塞门限"""
try:
self._send_command(f"%{threshold}E")
self.config.block_threshold = threshold
logger.debug(f"设置堵塞门限: {threshold}")
return True
except Exception as e:
logger.error(f"设置堵塞门限失败: {str(e)}")
return False
def set_tip_volume(self, volume: int) -> bool:
"""设置吸头容量"""
try:
self._send_command(f"C{volume}E")
self.config.tip_volume = volume
logger.debug(f"设置吸头容量: {volume}ul")
return True
except Exception as e:
logger.error(f"设置吸头容量失败: {str(e)}")
return False
def set_calibration_factor(self, factor: float) -> bool:
"""设置校准系数"""
try:
self._send_command(f"j{factor}E")
self.config.calibration_factor = factor
logger.debug(f"设置校准系数: {factor}")
return True
except Exception as e:
logger.error(f"设置校准系数失败: {str(e)}")
return False
def set_detection_mode(self, mode: DetectionMode) -> bool:
"""设置液位检测模式"""
try:
self._send_command(f"m{mode.value}E")
self.config.detection_mode = mode
logger.debug(f"设置检测模式: {mode.name}")
return True
except Exception as e:
logger.error(f"设置检测模式失败: {str(e)}")
return False
def set_lld_speed(self, speed: int) -> bool:
"""设置液位检测速度"""
try:
if 100 <= speed <= 2000:
self._send_command(f"k{speed}E")
self.config.lld_speed = speed
logger.debug(f"设置检测速度: {speed}")
return True
else:
logger.error("检测速度超出范围 (100~2000)")
return False
except Exception as e:
logger.error(f"设置检测速度失败: {str(e)}")
return False
# ==================== 状态查询方法 ====================
def get_status(self) -> SOPAStatusCode:
"""
获取设备状态
Returns:
SOPAStatusCode: 当前状态码
"""
try:
response = self._send_query("Q")
if response and len(response) > 8:
# 解析状态字节
status_char = response[8] if len(response) > 8 else '0'
try:
status_code = int(status_char, 16) if status_char.isdigit() or status_char.lower() in 'abcdef' else 0
self._last_status = SOPAStatusCode(status_code)
except ValueError:
self._last_status = SOPAStatusCode.NO_ERROR
return self._last_status
except Exception as e:
logger.error(f"获取状态失败: {str(e)}")
return SOPAStatusCode.NO_ERROR
def get_firmware_version(self) -> Optional[str]:
"""
获取固件版本信息
处理SOPA移液器的双响应帧格式
Returns:
Optional[str]: 固件版本字符串获取失败返回None
"""
try:
if not self.is_connected:
logger.debug("设备未连接,无法查询版本")
return "设备未连接"
# 清空串口缓冲区,避免残留数据干扰
if self.serial_port and self.serial_port.in_waiting > 0:
logger.debug(f"清空缓冲区中的 {self.serial_port.in_waiting} 字节数据")
self.serial_port.reset_input_buffer()
# 发送版本查询命令 - 使用VE命令
command = self._build_command("VE")
logger.debug(f"发送版本查询命令: {command}")
self.serial_port.write(command)
# 等待响应
time.sleep(0.3) # 增加等待时间
# 读取所有可用数据
all_data = b''
timeout_count = 0
max_timeout = 15 # 增加最大等待时间到1.5秒
while timeout_count < max_timeout:
if self.serial_port.in_waiting > 0:
data = self.serial_port.read(self.serial_port.in_waiting)
all_data += data
logger.debug(f"接收到 {len(data)} 字节数据: {data.hex().upper()}")
timeout_count = 0 # 重置超时计数
else:
time.sleep(0.1)
timeout_count += 1
# 检查是否收到完整的双响应帧
if len(all_data) >= 26: # 两个13字节的响应帧
logger.debug("收到完整的双响应帧")
break
elif len(all_data) >= 13: # 至少一个响应帧
# 继续等待一段时间看是否有第二个帧
if timeout_count > 5: # 等待0.5秒后如果没有更多数据就停止
logger.debug("只收到单响应帧")
break
logger.debug(f"总共接收到 {len(all_data)} 字节数据: {all_data.hex().upper()}")
if len(all_data) < 13:
logger.warning("接收到的数据不足一个完整响应帧")
return "版本信息不可用"
# 解析响应数据
version_info = self._parse_version_response(all_data)
logger.info(f"解析得到版本信息: {version_info}")
return version_info
except Exception as e:
logger.error(f"获取固件版本失败: {str(e)}")
return "版本信息不可用"
def _parse_version_response(self, data: bytes) -> str:
"""
解析版本响应数据
Args:
data: 原始响应数据
Returns:
str: 解析后的版本信息
"""
try:
# 将数据转换为十六进制字符串用于调试
hex_data = data.hex().upper()
logger.debug(f"收到版本响应数据: {hex_data}")
# 查找响应帧的起始位置
responses = []
i = 0
while i < len(data) - 12:
# 查找帧头 0x2F (/)
if data[i] == 0x2F:
# 检查是否是完整的13字节帧
if i + 12 < len(data) and data[i + 11] == 0x45: # 尾码 E
frame = data[i:i+13]
responses.append(frame)
i += 13
else:
i += 1
else:
i += 1
if len(responses) < 2:
# 如果只有一个响应帧,尝试解析
if len(responses) == 1:
return self._extract_version_from_frame(responses[0])
else:
return f"响应格式异常: {hex_data}"
# 解析第二个响应帧(通常包含版本信息)
version_frame = responses[1]
return self._extract_version_from_frame(version_frame)
except Exception as e:
logger.error(f"解析版本响应失败: {str(e)}")
return f"解析失败: {data.hex().upper()}"
def _extract_version_from_frame(self, frame: bytes) -> str:
"""
从响应帧中提取版本信息
Args:
frame: 13字节的响应帧
Returns:
str: 版本信息字符串
"""
try:
# 帧格式: 头码(1) + 地址(1) + 数据(9) + 尾码(1) + 校验和(1)
if len(frame) != 13:
return f"帧长度错误: {frame.hex().upper()}"
# 提取数据部分 (索引2-10共9字节)
data_part = frame[2:11]
# 尝试不同的解析方法
version_candidates = []
# 方法1: 查找可打印的ASCII字符
ascii_chars = []
for byte in data_part:
if 32 <= byte <= 126: # 可打印ASCII范围
ascii_chars.append(chr(byte))
if ascii_chars:
version_candidates.append(''.join(ascii_chars))
# 方法2: 解析为版本号格式 (如果前几个字节是版本信息)
if len(data_part) >= 3:
# 检查是否是 V.x.y 格式
if data_part[0] == 0x56: # 'V'
version_str = f"V{data_part[1]}.{data_part[2]}"
version_candidates.append(version_str)
# 方法3: 十六进制表示
hex_version = ' '.join(f'{b:02X}' for b in data_part)
version_candidates.append(f"HEX: {hex_version}")
# 返回最合理的版本信息
for candidate in version_candidates:
if candidate and len(candidate.strip()) > 1:
return candidate.strip()
return f"原始数据: {frame.hex().upper()}"
except Exception as e:
logger.error(f"提取版本信息失败: {str(e)}")
return f"提取失败: {frame.hex().upper()}"
def get_current_position(self) -> float:
"""
获取当前位置
Returns:
float: 当前位置 (微升)
"""
try:
response = self._send_query("Q18")
if response and len(response) > 10:
# 解析位置信息
pos_str = response[8:14].strip()
try:
self._current_position = int(pos_str)
except ValueError:
pass
except Exception as e:
logger.error(f"获取位置失败: {str(e)}")
return self._current_position
def get_device_info(self) -> Dict[str, Any]:
"""
获取设备完整信息
Returns:
Dict[str, Any]: 设备信息字典
"""
info = {
'firmware_version': self.get_firmware_version(),
'current_position': self.get_current_position(),
'tip_present': self.get_tip_status(),
'status': self.get_status(),
'is_connected': self.is_connected,
'is_initialized': self.is_initialized,
'config': {
'address': self.config.address,
'baudrate': self.config.baudrate,
'max_speed': self.config.max_speed,
'tip_volume': self.config.tip_volume,
'detection_mode': self.config.detection_mode.name
}
}
return info
# ==================== 高级操作方法 ====================
def transfer_liquid(self, source_volume: float, dispense_volume: float = None,
with_detection: bool = True, pre_wet: bool = False) -> bool:
"""
完整的液体转移操作
Args:
source_volume: 从源容器抽吸的体积
dispense_volume: 分配到目标容器的体积(默认等于抽吸体积)
with_detection: 是否使用液体检测
pre_wet: 是否进行预润湿
Returns:
bool: 操作是否成功
"""
try:
if not self.is_initialized:
raise SOPADeviceError("设备未初始化")
dispense_volume = dispense_volume or source_volume
logger.info(f"开始液体转移: 抽吸{source_volume}ul -> 分配{dispense_volume}ul")
# 预润湿(如果需要)
if pre_wet:
logger.info("执行预润湿操作")
if not self.aspirate(source_volume * 0.1, with_detection):
return False
if not self.dispense(source_volume * 0.1):
return False
# 执行液位检测(如果启用)
if with_detection:
if not self.liquid_level_detection():
logger.warning("液位检测失败,继续执行")
# 抽吸液体
if not self.aspirate(source_volume, with_detection):
logger.error("抽吸失败")
return False
# 可选的延时
time.sleep(0.5)
# 分配液体
if not self.dispense(dispense_volume, with_detection):
logger.error("分配失败")
return False
logger.info("液体转移完成")
return True
except Exception as e:
logger.error(f"液体转移失败: {str(e)}")
return False
@contextmanager
def batch_operation(self):
"""批量操作上下文管理器"""
logger.info("开始批量操作")
try:
yield self
finally:
logger.info("批量操作完成")
def reset_to_home(self) -> bool:
"""回到初始位置"""
return self.move_absolute(0)
def emergency_stop(self):
"""紧急停止"""
try:
if self.serial_port and self.serial_port.is_open:
# 发送停止命令(如果协议支持)
self.serial_port.write(b'\x03') # Ctrl+C
logger.warning("执行紧急停止")
except Exception as e:
logger.error(f"紧急停止失败: {str(e)}")
def __enter__(self):
"""上下文管理器入口"""
if not self.is_connected:
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器出口"""
self.disconnect()
def __del__(self):
"""析构函数"""
self.disconnect()
# ==================== 工厂函数和便利方法 ====================
def create_sopa_pipette(port: str = "/dev/ttyUSB0", address: int = 1,
baudrate: int = 115200, **kwargs) -> SOPAPipette:
"""
创建SOPA移液器实例的便利函数
Args:
port: 串口端口
address: RS485地址
baudrate: 波特率
**kwargs: 其他配置参数
Returns:
SOPAPipette: 移液器实例
"""
config = SOPAConfig(
port=port,
address=address,
baudrate=baudrate,
**kwargs
)
return SOPAPipette(config)