mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-19 14:01:20 +00:00
Merge dev branch: Add battery resources, bioyond_cell device registry, and fix file path resolution
This commit is contained in:
@@ -1 +1 @@
|
||||
__version__ = "0.10.7"
|
||||
__version__ = "0.10.12"
|
||||
|
||||
@@ -141,7 +141,7 @@ class CommunicationClientFactory:
|
||||
"""
|
||||
if cls._client_cache is None:
|
||||
cls._client_cache = cls.create_client(protocol)
|
||||
logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
|
||||
logger.trace(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client")
|
||||
|
||||
return cls._client_cache
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ if unilabos_dir not in sys.path:
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
|
||||
@@ -41,7 +42,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
for i, arg in enumerate(sys.argv):
|
||||
for option_string in option_strings:
|
||||
if arg.startswith(option_string):
|
||||
new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
|
||||
new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :]
|
||||
sys.argv[i] = new_arg
|
||||
break
|
||||
|
||||
@@ -49,6 +50,8 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
||||
subparsers = parser.add_subparsers(title="Valid subcommands", dest="command")
|
||||
|
||||
parser.add_argument("-g", "--graph", help="Physical setup graph file path.")
|
||||
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.")
|
||||
parser.add_argument(
|
||||
@@ -105,7 +108,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8002,
|
||||
default=None,
|
||||
help="Port for web service information page",
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -153,21 +156,54 @@ def parse_args():
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
aliases=["wf"],
|
||||
help="Upload workflow from xdl/json/python files",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"-f",
|
||||
"--workflow_file",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the workflow file (JSON format)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"-n",
|
||||
"--workflow_name",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Workflow name, if not provided will use the name from file or filename",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--tags",
|
||||
type=str,
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="Tags for the workflow (space-separated)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--published",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Whether to publish the workflow (default: False)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 解析命令行参数
|
||||
args = parse_args()
|
||||
convert_argv_dashes_to_underscores(args)
|
||||
args_dict = vars(args.parse_args())
|
||||
parser = parse_args()
|
||||
convert_argv_dashes_to_underscores(parser)
|
||||
args = parser.parse_args()
|
||||
args_dict = vars(args)
|
||||
|
||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||
if not args_dict.get("skip_env_check", False):
|
||||
from unilabos.utils.environment_check import check_environment
|
||||
|
||||
print_status("正在进行环境依赖检查...", "info")
|
||||
if not check_environment(auto_install=True):
|
||||
print_status("环境检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
@@ -218,19 +254,20 @@ def main():
|
||||
|
||||
if hasattr(BasicConfig, "log_level"):
|
||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||
configure_logger(loglevel=BasicConfig.log_level)
|
||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
|
||||
if args_dict["addr"] == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args_dict["addr"] == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
else:
|
||||
HTTPConfig.remote_addr = args_dict.get("addr", "")
|
||||
if args.addr != parser.get_default("addr"):
|
||||
if args.addr == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args.addr == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args.addr == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
else:
|
||||
HTTPConfig.remote_addr = args.addr
|
||||
|
||||
# 设置BasicConfig参数
|
||||
if args_dict.get("ak", ""):
|
||||
@@ -239,9 +276,12 @@ def main():
|
||||
if args_dict.get("sk", ""):
|
||||
BasicConfig.sk = args_dict.get("sk", "")
|
||||
print_status("传入了sk参数,优先采用传入参数!", "info")
|
||||
BasicConfig.working_dir = working_dir
|
||||
|
||||
workflow_upload = args_dict.get("command") in ("workflow_upload", "wf")
|
||||
|
||||
# 使用远程资源启动
|
||||
if args_dict["use_remote_resource"]:
|
||||
if not workflow_upload and args_dict["use_remote_resource"]:
|
||||
print_status("使用远程资源启动", "info")
|
||||
from unilabos.app.web import http_client
|
||||
|
||||
@@ -252,7 +292,8 @@ def main():
|
||||
else:
|
||||
print_status("远程资源不存在,本地将进行首次上报!", "info")
|
||||
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
||||
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||
@@ -281,9 +322,31 @@ def main():
|
||||
|
||||
# 注册表
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry
|
||||
)
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
# 处理 workflow_upload 子命令
|
||||
if workflow_upload:
|
||||
from unilabos.workflow.wf_utils import handle_workflow_upload_command
|
||||
|
||||
handle_workflow_upload_command(args_dict)
|
||||
print_status("工作流上传完成,程序退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
@@ -291,7 +354,9 @@ def main():
|
||||
resource_tree_set: ResourceTreeSet
|
||||
resource_links: List[Dict[str, Any]]
|
||||
request_startup_json = http_client.request_startup_json()
|
||||
if args_dict["graph"] is None:
|
||||
|
||||
file_path = args_dict.get("graph", BasicConfig.startup_json_path)
|
||||
if file_path is None:
|
||||
if not request_startup_json:
|
||||
print_status(
|
||||
"未指定设备加载文件路径,尝试从HTTP获取失败,请检查网络或者使用-g参数指定设备加载文件路径", "error"
|
||||
@@ -301,7 +366,38 @@ def main():
|
||||
print_status("联网获取设备加载文件成功", "info")
|
||||
graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json)
|
||||
else:
|
||||
file_path = args_dict["graph"]
|
||||
if not os.path.isfile(file_path):
|
||||
# 尝试从 main.py 向上两级目录查找
|
||||
temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path)))
|
||||
if os.path.isfile(temp_file_path):
|
||||
print_status(f"使用相对路径{temp_file_path}", "info")
|
||||
file_path = temp_file_path
|
||||
else:
|
||||
# 尝试在 working_dir 中查找
|
||||
working_dir_file_path = os.path.join(working_dir, file_path)
|
||||
if os.path.isfile(working_dir_file_path):
|
||||
print_status(f"在工作目录中找到文件: {working_dir_file_path}", "info")
|
||||
file_path = working_dir_file_path
|
||||
else:
|
||||
# 尝试使用文件名在 working_dir 中查找
|
||||
file_name = os.path.basename(file_path)
|
||||
working_dir_file_path = os.path.join(working_dir, file_name)
|
||||
if os.path.isfile(working_dir_file_path):
|
||||
print_status(f"在工作目录中找到文件: {working_dir_file_path}", "info")
|
||||
file_path = working_dir_file_path
|
||||
# 最终检查文件是否存在
|
||||
if not os.path.isfile(file_path):
|
||||
print_status(
|
||||
f"无法找到设备加载文件: {file_path}\n"
|
||||
f"已尝试在以下位置查找:\n"
|
||||
f" 1. 原始路径: {args_dict.get('graph', BasicConfig.startup_json_path)}\n"
|
||||
f" 2. 相对路径: {os.path.abspath(str(os.path.join(__file__, '..', '..', args_dict.get('graph', BasicConfig.startup_json_path) or '')))}\n"
|
||||
f" 3. 工作目录: {os.path.join(working_dir, args_dict.get('graph', BasicConfig.startup_json_path) or '')}\n"
|
||||
f" 4. 工作目录(仅文件名): {os.path.join(working_dir, os.path.basename(args_dict.get('graph', BasicConfig.startup_json_path) or ''))}\n"
|
||||
f"请使用 -g 参数指定正确的文件路径,或在工作目录 {working_dir} 中放置文件",
|
||||
"error"
|
||||
)
|
||||
os._exit(1)
|
||||
if file_path.endswith(".json"):
|
||||
graph, resource_tree_set, resource_links = read_node_link_json(file_path)
|
||||
else:
|
||||
@@ -354,20 +450,6 @@ def main():
|
||||
args_dict["devices_config"] = resource_tree_set
|
||||
args_dict["graph"] = graph_res.physical_setup_graph
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
if args_dict["controllers"] is not None:
|
||||
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
|
||||
else:
|
||||
@@ -382,6 +464,7 @@ def main():
|
||||
comm_client = get_communication_client()
|
||||
if "websocket" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(comm_client)
|
||||
|
||||
def _exit(signum, frame):
|
||||
comm_client.stop()
|
||||
sys.exit(0)
|
||||
@@ -413,26 +496,39 @@ def main():
|
||||
server_thread = threading.Thread(
|
||||
target=start_server,
|
||||
kwargs=dict(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
open_browser=not BasicConfig.disable_browser,
|
||||
port=BasicConfig.port,
|
||||
),
|
||||
)
|
||||
server_thread.start()
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
resource_visualization.start()
|
||||
try:
|
||||
resource_visualization.start()
|
||||
except OSError as e:
|
||||
if "AMENT_PREFIX_PATH" in str(e):
|
||||
print_status(f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}", "warning")
|
||||
print_status(
|
||||
"建议解决方案:\n"
|
||||
"1. 激活Conda环境: conda activate unilab\n"
|
||||
"2. 或使用 --backend simple 参数\n"
|
||||
"3. 或使用 --visual disable 参数禁用可视化",
|
||||
"info",
|
||||
)
|
||||
else:
|
||||
raise
|
||||
while True:
|
||||
time.sleep(1)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=args_dict["port"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,21 +51,25 @@ class Resp(BaseModel):
|
||||
class JobAddReq(BaseModel):
|
||||
device_id: str = Field(examples=["Gripper"], description="device id")
|
||||
action: str = Field(examples=["_execute_driver_command_async"], description="action name", default="")
|
||||
action_type: str = Field(examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action name", default="")
|
||||
action_args: dict = Field(examples=[{'string': 'string'}], description="action name", default="")
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid")
|
||||
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
|
||||
action_type: str = Field(
|
||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||
)
|
||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||
node_id: str = Field(examples=["node_id"], description="node uuid", default="")
|
||||
server_info: dict = Field(
|
||||
examples=[{"send_timestamp": 1717000000.0}],
|
||||
description="server info (auto-generated if empty)",
|
||||
default_factory=dict,
|
||||
)
|
||||
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default={})
|
||||
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default_factory=dict)
|
||||
|
||||
|
||||
class JobStepFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -83,9 +87,7 @@ class JobStepFinishReq(BaseModel):
|
||||
|
||||
class JobPreintakeFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -102,9 +104,7 @@ class JobPreintakeFinishReq(BaseModel):
|
||||
|
||||
class JobFinishReq(BaseModel):
|
||||
token: str = Field(examples=["030944"], description="token")
|
||||
request_time: str = Field(
|
||||
examples=["2024-12-12 12:12:12.xxx"], description="requestTime"
|
||||
)
|
||||
request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime")
|
||||
data: dict = Field(
|
||||
examples=[
|
||||
{
|
||||
@@ -133,6 +133,10 @@ class JobData(BaseModel):
|
||||
default=0,
|
||||
description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED",
|
||||
)
|
||||
result: dict = Field(
|
||||
default_factory=dict,
|
||||
description="Job result data (available when status is SUCCEEDED/CANCELED/ABORTED)",
|
||||
)
|
||||
|
||||
|
||||
class JobStatusResp(Resp):
|
||||
|
||||
@@ -1,161 +1,158 @@
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
from unilabos.config.config import OSSUploadConfig
|
||||
from unilabos.app.web.client import http_client, HTTPClient
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
def _init_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default",
|
||||
expires_hours: int = 1) -> Tuple[bool, Dict]:
|
||||
def _get_oss_token(
|
||||
filename: str,
|
||||
driver_name: str = "default",
|
||||
exp_type: str = "default",
|
||||
client: Optional[HTTPClient] = None,
|
||||
) -> Tuple[bool, Dict]:
|
||||
"""
|
||||
初始化上传过程
|
||||
获取OSS上传Token
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
expires_hours: 链接过期小时数
|
||||
filename: 文件名
|
||||
driver_name: 驱动名称
|
||||
exp_type: 实验类型
|
||||
client: HTTPClient实例,如果不提供则使用默认的http_client
|
||||
|
||||
Returns:
|
||||
(成功标志, 响应数据)
|
||||
(成功标志, Token数据字典包含token/path/host/expires)
|
||||
"""
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
# 使用提供的client或默认的http_client
|
||||
if client is None:
|
||||
client = http_client
|
||||
|
||||
# 构造初始化请求
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# 构造scene参数: driver_name-exp_type
|
||||
sub_path = f"{driver_name}-{exp_type}"
|
||||
|
||||
payload = {
|
||||
"device_id": device_id,
|
||||
"process_key": process_key,
|
||||
"filename": filename,
|
||||
"path": oss_path,
|
||||
"expires_hours": expires_hours
|
||||
}
|
||||
# 构造请求URL,使用client的remote_addr(已包含/api/v1/)
|
||||
url = f"{client.remote_addr}/applications/token"
|
||||
params = {"sub_path": sub_path, "filename": filename, "scene": "job"}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True, result.get("data", {})
|
||||
logger.info(f"[OSS] 请求预签名URL: sub_path={sub_path}, filename={filename}")
|
||||
response = requests.get(url, params=params, headers={"Authorization": f"Lab {client.auth}"}, timeout=10)
|
||||
|
||||
print(f"初始化上传失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
data = result.get("data", {})
|
||||
|
||||
# 转换expires时间戳为可读格式
|
||||
expires_timestamp = data.get("expires", 0)
|
||||
expires_datetime = datetime.fromtimestamp(expires_timestamp)
|
||||
expires_str = expires_datetime.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
logger.info(f"[OSS] 获取预签名URL成功")
|
||||
logger.info(f"[OSS] - URL: {data.get('url', 'N/A')}")
|
||||
logger.info(f"[OSS] - Expires: {expires_str} (timestamp: {expires_timestamp})")
|
||||
|
||||
return True, data
|
||||
|
||||
logger.error(f"[OSS] 获取预签名URL失败: {response.status_code}, {response.text}")
|
||||
return False, {}
|
||||
except Exception as e:
|
||||
print(f"初始化上传异常: {str(e)}")
|
||||
logger.error(f"[OSS] 获取预签名URL异常: {str(e)}")
|
||||
return False, {}
|
||||
|
||||
|
||||
def _put_upload(file_path: str, upload_url: str) -> bool:
|
||||
"""
|
||||
执行PUT上传
|
||||
使用预签名URL上传文件到OSS
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
upload_url: 上传URL
|
||||
upload_url: 完整的预签名上传URL
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[OSS] 开始上传文件: {file_path}")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
response = requests.put(upload_url, data=f)
|
||||
# 使用预签名URL上传,不需要额外的认证header
|
||||
response = requests.put(upload_url, data=f, timeout=300)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"[OSS] 文件上传成功")
|
||||
return True
|
||||
|
||||
print(f"PUT上传失败: {response.status_code}, {response.text}")
|
||||
logger.error(f"[OSS] 上传失败: {response.status_code}")
|
||||
logger.error(f"[OSS] 响应内容: {response.text[:500] if response.text else '无响应内容'}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"PUT上传异常: {str(e)}")
|
||||
logger.error(f"[OSS] 上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _complete_upload(uuid: str) -> bool:
|
||||
"""
|
||||
完成上传过程
|
||||
|
||||
Args:
|
||||
uuid: 上传的UUID
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.complete_endpoint}"
|
||||
headers = {
|
||||
"Authorization": OSSUploadConfig.authorization,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"uuid": uuid
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") == "10000":
|
||||
return True
|
||||
|
||||
print(f"完成上传失败: {response.status_code}, {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"完成上传异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
process_key: str = "file-upload", device_id: str = "default") -> bool:
|
||||
def oss_upload(
|
||||
file_path: Union[str, Path],
|
||||
filename: Optional[str] = None,
|
||||
driver_name: str = "default",
|
||||
exp_type: str = "default",
|
||||
max_retries: int = 3,
|
||||
client: Optional[HTTPClient] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
文件上传主函数,包含重试机制
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
oss_path: OSS目标路径
|
||||
filename: 文件名,如果为None则使用file_path的文件名
|
||||
process_key: 处理键
|
||||
device_id: 设备ID
|
||||
driver_name: 驱动名称,用于构造scene
|
||||
exp_type: 实验类型,用于构造scene
|
||||
max_retries: 最大重试次数
|
||||
client: HTTPClient实例,如果不提供则使用默认的http_client
|
||||
|
||||
Returns:
|
||||
是否成功上传
|
||||
Dict: {
|
||||
"success": bool, # 是否上传成功
|
||||
"original_path": str, # 原始文件路径
|
||||
"oss_path": str # OSS路径(成功时)或空字符串(失败时)
|
||||
}
|
||||
"""
|
||||
max_retries = OSSUploadConfig.max_retries
|
||||
file_path = Path(file_path)
|
||||
if filename is None:
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"[OSS] 文件不存在: {file_path}")
|
||||
return {"success": False, "original_path": file_path, "oss_path": ""}
|
||||
|
||||
retry_count = 0
|
||||
oss_path = ""
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
# 步骤1:初始化上传
|
||||
init_success, init_data = _init_upload(
|
||||
file_path=file_path,
|
||||
oss_path=oss_path,
|
||||
filename=filename,
|
||||
process_key=process_key,
|
||||
device_id=device_id
|
||||
# 步骤1:获取预签名URL
|
||||
token_success, token_data = _get_oss_token(
|
||||
filename=filename, driver_name=driver_name, exp_type=exp_type, client=client
|
||||
)
|
||||
|
||||
if not init_success:
|
||||
print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
if not token_success:
|
||||
logger.warning(f"[OSS] 获取预签名URL失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1) # 等待1秒后重试
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 获取UUID和上传URL
|
||||
uuid = init_data.get("uuid")
|
||||
upload_url = init_data.get("upload_url")
|
||||
# 获取预签名URL和OSS路径
|
||||
upload_url = token_data.get("url")
|
||||
oss_path = token_data.get("path", "")
|
||||
|
||||
if not uuid or not upload_url:
|
||||
print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}")
|
||||
if not upload_url:
|
||||
logger.warning(f"[OSS] 无法获取上传URL,API未返回url字段")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
@@ -163,69 +160,82 @@ def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None,
|
||||
# 步骤2:PUT上传文件
|
||||
put_success = _put_upload(file_path, upload_url)
|
||||
if not put_success:
|
||||
print(f"PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 步骤3:完成上传
|
||||
complete_success = _complete_upload(uuid)
|
||||
if not complete_success:
|
||||
print(f"完成上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
logger.warning(f"[OSS] PUT上传失败,重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# 所有步骤都成功
|
||||
print(f"文件 {file_path} 上传成功")
|
||||
return True
|
||||
logger.info(f"[OSS] 文件 {file_path} 上传成功")
|
||||
return {"success": True, "original_path": file_path, "oss_path": oss_path}
|
||||
|
||||
except Exception as e:
|
||||
print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
logger.error(f"[OSS] 上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}")
|
||||
retry_count += 1
|
||||
time.sleep(1)
|
||||
|
||||
print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return False
|
||||
logger.error(f"[OSS] 文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}")
|
||||
return {"success": False, "original_path": file_path, "oss_path": oss_path}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test
|
||||
# python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test \
|
||||
# --ak xxx --sk yyy --remote-addr http://xxx/api/v1
|
||||
# 命令行参数解析
|
||||
parser = argparse.ArgumentParser(description='文件上传测试工具')
|
||||
parser.add_argument('--file', '-f', type=str, required=True, help='要上传的本地文件路径')
|
||||
parser.add_argument('--path', '-p', type=str, default='/HPLC1/Any', help='OSS目标路径')
|
||||
parser.add_argument('--device', '-d', type=str, default='test-device', help='设备ID')
|
||||
parser.add_argument('--process', '-k', type=str, default='HPLC-txt-result', help='处理键')
|
||||
parser = argparse.ArgumentParser(description="文件上传测试工具")
|
||||
parser.add_argument("--file", "-f", type=str, required=True, help="要上传的本地文件路径")
|
||||
parser.add_argument("--driver", "-d", type=str, default="default", help="驱动名称")
|
||||
parser.add_argument("--type", "-t", type=str, default="default", help="实验类型")
|
||||
parser.add_argument("--ak", type=str, help="Access Key,如果提供则覆盖配置")
|
||||
parser.add_argument("--sk", type=str, help="Secret Key,如果提供则覆盖配置")
|
||||
parser.add_argument("--remote-addr", type=str, help="远程服务器地址(包含/api/v1),如果提供则覆盖配置")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(args.file):
|
||||
print(f"错误:文件 {args.file} 不存在")
|
||||
logger.error(f"错误:文件 {args.file} 不存在")
|
||||
exit(1)
|
||||
|
||||
print("=" * 50)
|
||||
print(f"开始上传文件: {args.file}")
|
||||
print(f"目标路径: {args.path}")
|
||||
print(f"设备ID: {args.device}")
|
||||
print(f"处理键: {args.process}")
|
||||
print("=" * 50)
|
||||
# 如果提供了ak/sk/remote_addr,创建临时HTTPClient
|
||||
temp_client = None
|
||||
if args.ak and args.sk:
|
||||
import base64
|
||||
|
||||
auth = base64.b64encode(f"{args.ak}:{args.sk}".encode("utf-8")).decode("utf-8")
|
||||
remote_addr = args.remote_addr if args.remote_addr else http_client.remote_addr
|
||||
temp_client = HTTPClient(remote_addr=remote_addr, auth=auth)
|
||||
logger.info(f"[配置] 使用自定义配置: remote_addr={remote_addr}")
|
||||
elif args.remote_addr:
|
||||
temp_client = HTTPClient(remote_addr=args.remote_addr, auth=http_client.auth)
|
||||
logger.info(f"[配置] 使用自定义remote_addr: {args.remote_addr}")
|
||||
else:
|
||||
logger.info(f"[配置] 使用默认配置: remote_addr={http_client.remote_addr}")
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"开始上传文件: {args.file}")
|
||||
logger.info(f"驱动名称: {args.driver}")
|
||||
logger.info(f"实验类型: {args.type}")
|
||||
logger.info(f"Scene: {args.driver}-{args.type}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# 执行上传
|
||||
success = oss_upload(
|
||||
result = oss_upload(
|
||||
file_path=args.file,
|
||||
oss_path=args.path,
|
||||
filename=None, # 使用默认文件名
|
||||
process_key=args.process,
|
||||
device_id=args.device
|
||||
driver_name=args.driver,
|
||||
exp_type=args.type,
|
||||
client=temp_client,
|
||||
)
|
||||
|
||||
# 输出结果
|
||||
if success:
|
||||
print("\n√ 文件上传成功!")
|
||||
if result["success"]:
|
||||
logger.info(f"\n√ 文件上传成功!")
|
||||
logger.info(f"原始路径: {result['original_path']}")
|
||||
logger.info(f"OSS路径: {result['oss_path']}")
|
||||
exit(0)
|
||||
else:
|
||||
print("\n× 文件上传失败!")
|
||||
logger.error(f"\n× 文件上传失败!")
|
||||
logger.error(f"原始路径: {result['original_path']}")
|
||||
exit(1)
|
||||
|
||||
|
||||
@@ -9,13 +9,22 @@ import asyncio
|
||||
|
||||
import yaml
|
||||
|
||||
from unilabos.app.web.controler import devices, job_add, job_info
|
||||
from unilabos.app.web.controller import (
|
||||
devices,
|
||||
job_add,
|
||||
job_info,
|
||||
get_online_devices,
|
||||
get_device_actions,
|
||||
get_action_schema,
|
||||
get_all_available_actions,
|
||||
)
|
||||
from unilabos.app.model import (
|
||||
Resp,
|
||||
RespCode,
|
||||
JobStatusResp,
|
||||
JobAddResp,
|
||||
JobAddReq,
|
||||
JobData,
|
||||
)
|
||||
from unilabos.app.web.utils.host_utils import get_host_node_info
|
||||
from unilabos.registry.registry import lab_registry
|
||||
@@ -1234,6 +1243,65 @@ def get_devices():
|
||||
return Resp(data=dict(data))
|
||||
|
||||
|
||||
@api.get("/online-devices", summary="Online devices list", response_model=Resp)
|
||||
def api_get_online_devices():
|
||||
"""获取在线设备列表
|
||||
|
||||
返回当前在线的设备列表,包含设备ID、命名空间、机器名等信息
|
||||
"""
|
||||
isok, data = get_online_devices()
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/devices/{device_id}/actions", summary="Device actions list", response_model=Resp)
|
||||
def api_get_device_actions(device_id: str):
|
||||
"""获取设备可用的动作列表
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
|
||||
返回指定设备的所有可用动作,包含动作名称、类型、是否繁忙等信息
|
||||
"""
|
||||
isok, data = get_device_actions(device_id)
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/devices/{device_id}/actions/{action_name}/schema", summary="Action schema", response_model=Resp)
|
||||
def api_get_action_schema(device_id: str, action_name: str):
|
||||
"""获取动作的Schema详情
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
返回动作的参数Schema、默认值、类型等详细信息
|
||||
"""
|
||||
isok, data = get_action_schema(device_id, action_name)
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/actions", summary="All available actions", response_model=Resp)
|
||||
def api_get_all_actions():
|
||||
"""获取所有设备的可用动作
|
||||
|
||||
返回所有已注册设备的动作列表,包含设备信息和各动作的状态
|
||||
"""
|
||||
isok, data = get_all_available_actions()
|
||||
if not isok:
|
||||
return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error"))
|
||||
|
||||
return Resp(data=data)
|
||||
|
||||
|
||||
@api.get("/job/{id}/status", summary="Job status", response_model=JobStatusResp)
|
||||
def job_status(id: str):
|
||||
"""获取任务状态"""
|
||||
@@ -1244,11 +1312,22 @@ def job_status(id: str):
|
||||
@api.post("/job/add", summary="Create job", response_model=JobAddResp)
|
||||
def post_job_add(req: JobAddReq):
|
||||
"""创建任务"""
|
||||
device_id = req.device_id
|
||||
if not req.data:
|
||||
return Resp(code=RespCode.ErrorInvalidReq, message="Invalid request data")
|
||||
# 检查必要参数:device_id 和 action
|
||||
if not req.device_id:
|
||||
return JobAddResp(
|
||||
data=JobData(jobId="", status=6),
|
||||
code=RespCode.ErrorInvalidReq,
|
||||
message="device_id is required",
|
||||
)
|
||||
|
||||
action_name = req.data.get("action", req.action) if req.data else req.action
|
||||
if not action_name:
|
||||
return JobAddResp(
|
||||
data=JobData(jobId="", status=6),
|
||||
code=RespCode.ErrorInvalidReq,
|
||||
message="action is required",
|
||||
)
|
||||
|
||||
req.device_id = device_id
|
||||
data = job_add(req)
|
||||
return JobAddResp(data=data)
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ class HTTPClient:
|
||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
|
||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
||||
f.write(json.dumps(payload, indent=4))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
if not self.initialized or first_add:
|
||||
@@ -331,6 +332,67 @@ class HTTPClient:
|
||||
logger.error(f"响应内容: {response.text}")
|
||||
return None
|
||||
|
||||
def workflow_import(
|
||||
self,
|
||||
name: str,
|
||||
workflow_uuid: str,
|
||||
workflow_name: str,
|
||||
nodes: List[Dict[str, Any]],
|
||||
edges: List[Dict[str, Any]],
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
workflow_uuid: 工作流UUID
|
||||
workflow_name: 工作流名称(data内部)
|
||||
nodes: 工作流节点列表
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
"""
|
||||
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
|
||||
payload = {
|
||||
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
|
||||
"name": name,
|
||||
"data": {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"workflow_name": workflow_name,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
"published": published,
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
||||
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
# 保存响应到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
||||
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"导入工作流失败: {response.text}")
|
||||
return res
|
||||
else:
|
||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
|
||||
# 创建默认客户端实例
|
||||
http_client = HTTPClient()
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
|
||||
import json
|
||||
import traceback
|
||||
import uuid
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
|
||||
|
||||
def get_resources() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().resources_config
|
||||
|
||||
def devices() -> tuple:
|
||||
if HostNode.get_instance() is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, HostNode.get_instance().devices_config
|
||||
|
||||
def job_info(id: str):
|
||||
get_goal_status = HostNode.get_instance().get_goal_status(id)
|
||||
return JobData(jobId=id, status=get_goal_status)
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
if req.job_id is None:
|
||||
req.job_id = str(uuid.uuid4())
|
||||
action_name = req.data["action"]
|
||||
action_type = req.data.get("action_type", "LocalUnknown")
|
||||
action_args = req.data.get("action_kwargs", None) # 兼容老版本,后续删除
|
||||
if action_args is None:
|
||||
action_args = req.data.get("action_args")
|
||||
else:
|
||||
if "command" in action_args:
|
||||
action_args = action_args["command"]
|
||||
# print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
|
||||
try:
|
||||
HostNode.get_instance().send_goal(req.device_id, action_type=action_type, action_name=action_name, action_kwargs=action_args, goal_uuid=req.job_id, server_info=req.server_info)
|
||||
except Exception as e:
|
||||
for bridge in HostNode.get_instance().bridges:
|
||||
traceback.print_exc()
|
||||
if hasattr(bridge, "publish_job_status"):
|
||||
bridge.publish_job_status({}, req.job_id, "failed", serialize_result_info(traceback.format_exc(), False, {}))
|
||||
return JobData(jobId=req.job_id)
|
||||
587
unilabos/app/web/controller.py
Normal file
587
unilabos/app/web/controller.py
Normal file
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
Web API Controller
|
||||
|
||||
提供Web API的控制器函数,处理设备、任务和动作相关的业务逻辑
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from unilabos.app.model import JobAddReq, JobData
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobResult:
|
||||
"""任务结果数据"""
|
||||
|
||||
job_id: str
|
||||
status: int # 4:SUCCEEDED, 5:CANCELED, 6:ABORTED
|
||||
result: Dict[str, Any] = field(default_factory=dict)
|
||||
feedback: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class JobResultStore:
|
||||
"""任务结果存储(单例)"""
|
||||
|
||||
_instance: Optional["JobResultStore"] = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, "_initialized"):
|
||||
self._results: Dict[str, JobResult] = {}
|
||||
self._results_lock = threading.RLock()
|
||||
self._initialized = True
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def store_result(
|
||||
self, job_id: str, status: int, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""存储任务结果"""
|
||||
with self._results_lock:
|
||||
self._results[job_id] = JobResult(
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
result=result or {},
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
"""仅获取任务结果(不删除)"""
|
||||
with self._results_lock:
|
||||
return self._results.get(job_id)
|
||||
|
||||
def cleanup_old_results(self, max_age_seconds: float = 3600):
|
||||
"""清理过期的结果"""
|
||||
current_time = time.time()
|
||||
with self._results_lock:
|
||||
expired_jobs = [
|
||||
job_id for job_id, result in self._results.items() if current_time - result.timestamp > max_age_seconds
|
||||
]
|
||||
for job_id in expired_jobs:
|
||||
del self._results[job_id]
|
||||
logger.debug(f"[JobResultStore] Cleaned up expired result for job {job_id[:8]}")
|
||||
|
||||
|
||||
# 全局结果存储实例
|
||||
job_result_store = JobResultStore()
|
||||
|
||||
|
||||
def store_job_result(
|
||||
job_id: str, status: str, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""存储任务结果(供外部调用)
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
status: 状态字符串 ("success", "failed", "cancelled")
|
||||
result: 结果数据
|
||||
feedback: 反馈数据
|
||||
"""
|
||||
# 转换状态字符串为整数
|
||||
status_map = {
|
||||
"success": 4, # SUCCEEDED
|
||||
"failed": 6, # ABORTED
|
||||
"cancelled": 5, # CANCELED
|
||||
"running": 2, # EXECUTING
|
||||
}
|
||||
status_int = status_map.get(status, 0)
|
||||
|
||||
# 只存储最终状态
|
||||
if status_int in (4, 5, 6):
|
||||
job_result_store.store_result(job_id, status_int, result, feedback)
|
||||
|
||||
|
||||
def get_resources() -> Tuple[bool, Any]:
|
||||
"""获取资源配置
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Any]: (是否成功, 资源配置或错误信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, host_node.resources_config
|
||||
|
||||
|
||||
def devices() -> Tuple[bool, Any]:
|
||||
"""获取设备配置
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Any]: (是否成功, 设备配置或错误信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, "Host node not initialized"
|
||||
|
||||
return True, host_node.devices_config
|
||||
|
||||
|
||||
def job_info(job_id: str, remove_after_read: bool = True) -> JobData:
|
||||
"""获取任务信息
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
remove_after_read: 是否在读取后删除结果(默认True)
|
||||
|
||||
Returns:
|
||||
JobData: 任务数据
|
||||
"""
|
||||
# 首先检查结果存储中是否有已完成的结果
|
||||
if remove_after_read:
|
||||
stored_result = job_result_store.get_and_remove(job_id)
|
||||
else:
|
||||
stored_result = job_result_store.get_result(job_id)
|
||||
|
||||
if stored_result:
|
||||
# 有存储的结果,直接返回
|
||||
return JobData(
|
||||
jobId=job_id,
|
||||
status=stored_result.status,
|
||||
result=stored_result.result,
|
||||
)
|
||||
|
||||
# 没有存储的结果,从 HostNode 获取当前状态
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return JobData(jobId=job_id, status=0)
|
||||
|
||||
get_goal_status = host_node.get_goal_status(job_id)
|
||||
return JobData(jobId=job_id, status=get_goal_status)
|
||||
|
||||
|
||||
def check_device_action_busy(device_id: str, action_name: str) -> Tuple[bool, Optional[str]]:
|
||||
"""检查设备动作是否正在执行(被占用)
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (是否繁忙, 当前执行的job_id或None)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, None
|
||||
|
||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||
|
||||
# 检查 _device_action_status 中是否有正在执行的任务
|
||||
if device_action_key in host_node._device_action_status:
|
||||
status = host_node._device_action_status[device_action_key]
|
||||
if status.job_ids:
|
||||
# 返回第一个正在执行的job_id
|
||||
current_job_id = next(iter(status.job_ids.keys()), None)
|
||||
return True, current_job_id
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def _get_action_type(device_id: str, action_name: str) -> Optional[str]:
|
||||
"""从注册表自动获取动作类型
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
动作类型字符串,未找到返回None
|
||||
"""
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
# 方法1: 从运行时注册设备获取
|
||||
if device_id in registered_devices:
|
||||
device_info = registered_devices[device_id]
|
||||
base_node = device_info.get("base_node_instance")
|
||||
if base_node and hasattr(base_node, "_action_value_mappings"):
|
||||
action_mappings = base_node._action_value_mappings
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
action_type = action_mappings[key].get("type")
|
||||
if action_type:
|
||||
# 转换为字符串格式
|
||||
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
|
||||
return f"{action_type.__module__}.{action_type.__name__}"
|
||||
return str(action_type)
|
||||
|
||||
# 方法2: 从lab_registry获取
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node and lab_registry:
|
||||
devices_config = host_node.devices_config
|
||||
device_class = None
|
||||
|
||||
for tree in devices_config.trees:
|
||||
node = tree.root_node
|
||||
if node.res_content.id == device_id:
|
||||
device_class = node.res_content.klass
|
||||
break
|
||||
|
||||
if device_class and device_class in lab_registry.device_type_registry:
|
||||
device_type_info = lab_registry.device_type_registry[device_class]
|
||||
class_info = device_type_info.get("class", {})
|
||||
action_mappings = class_info.get("action_value_mappings", {})
|
||||
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
action_type = action_mappings[key].get("type")
|
||||
if action_type:
|
||||
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
|
||||
return f"{action_type.__module__}.{action_type.__name__}"
|
||||
return str(action_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Failed to get action type for {device_id}/{action_name}: {str(e)}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def job_add(req: JobAddReq) -> JobData:
|
||||
"""添加任务(检查设备是否繁忙,繁忙则返回失败)
|
||||
|
||||
Args:
|
||||
req: 任务添加请求
|
||||
|
||||
Returns:
|
||||
JobData: 任务数据(包含状态)
|
||||
"""
|
||||
# 服务端自动生成 job_id 和 task_id
|
||||
job_id = str(uuid.uuid4())
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 服务端自动生成 server_info
|
||||
server_info = {"send_timestamp": time.time()}
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
logger.error(f"[Controller] Host node not initialized for job: {job_id[:8]}")
|
||||
return JobData(jobId=job_id, status=6) # 6 = ABORTED
|
||||
|
||||
# 解析动作信息
|
||||
action_name = req.data.get("action", req.action) if req.data else req.action
|
||||
action_args = req.data.get("action_kwargs") or req.data.get("action_args") if req.data else req.action_args
|
||||
|
||||
if action_args is None:
|
||||
action_args = req.action_args or {}
|
||||
elif isinstance(action_args, dict) and "command" in action_args:
|
||||
action_args = action_args["command"]
|
||||
|
||||
# 自动获取 action_type
|
||||
action_type = _get_action_type(req.device_id, action_name)
|
||||
if action_type is None:
|
||||
logger.error(f"[Controller] Action type not found for {req.device_id}/{action_name}")
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
# 检查设备动作是否繁忙
|
||||
is_busy, current_job_id = check_device_action_busy(req.device_id, action_name)
|
||||
|
||||
if is_busy:
|
||||
logger.warning(
|
||||
f"[Controller] Device action busy: {req.device_id}/{action_name}, "
|
||||
f"current job: {current_job_id[:8] if current_job_id else 'unknown'}"
|
||||
)
|
||||
# 返回失败状态,status=6 表示 ABORTED
|
||||
return JobData(jobId=job_id, status=6)
|
||||
|
||||
# 设备空闲,提交任务执行
|
||||
try:
|
||||
from unilabos.app.ws_client import QueueItem
|
||||
|
||||
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
||||
queue_item = QueueItem(
|
||||
task_type="job_call_back_status",
|
||||
device_id=req.device_id,
|
||||
action_name=action_name,
|
||||
task_id=task_id,
|
||||
job_id=job_id,
|
||||
device_action_key=device_action_key,
|
||||
)
|
||||
|
||||
host_node.send_goal(
|
||||
queue_item,
|
||||
action_type=action_type,
|
||||
action_kwargs=action_args,
|
||||
server_info=server_info,
|
||||
)
|
||||
|
||||
logger.info(f"[Controller] Job submitted: {job_id[:8]} -> {req.device_id}/{action_name}")
|
||||
# 返回已接受状态,status=1 表示 ACCEPTED
|
||||
return JobData(jobId=job_id, status=1)
|
||||
|
||||
except ValueError as e:
|
||||
# ActionClient not found 等错误
|
||||
logger.error(f"[Controller] Action not available: {str(e)}")
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error submitting job: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return JobData(jobId=job_id, status=6) # ABORTED
|
||||
|
||||
|
||||
def get_online_devices() -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取在线设备列表
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 在线设备信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
online_devices = {}
|
||||
for device_key in host_node._online_devices:
|
||||
# device_key 格式: "namespace/device_id"
|
||||
parts = device_key.split("/")
|
||||
if len(parts) >= 2:
|
||||
device_id = parts[-1]
|
||||
else:
|
||||
device_id = device_key
|
||||
|
||||
# 获取设备详细信息
|
||||
device_info = registered_devices.get(device_id, {})
|
||||
machine_name = host_node.device_machine_names.get(device_id, "未知")
|
||||
|
||||
online_devices[device_id] = {
|
||||
"device_key": device_key,
|
||||
"namespace": host_node.devices_names.get(device_id, ""),
|
||||
"machine_name": machine_name,
|
||||
"uuid": device_info.get("uuid", "") if device_info else "",
|
||||
"node_name": device_info.get("node_name", "") if device_info else "",
|
||||
}
|
||||
|
||||
return True, {
|
||||
"online_devices": online_devices,
|
||||
"total_count": len(online_devices),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting online devices: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_device_actions(device_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取设备可用的动作列表
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 动作列表信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
from unilabos.app.web.utils.action_utils import get_action_info
|
||||
|
||||
# 检查设备是否已注册
|
||||
if device_id not in registered_devices:
|
||||
return False, {"error": f"Device not found: {device_id}"}
|
||||
|
||||
device_info = registered_devices[device_id]
|
||||
actions = device_info.get("actions", {})
|
||||
|
||||
actions_list = {}
|
||||
for action_name, action_server in actions.items():
|
||||
try:
|
||||
action_info = get_action_info(action_server, action_name)
|
||||
# 检查动作是否繁忙
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
actions_list[action_name] = {
|
||||
**action_info,
|
||||
"is_busy": is_busy,
|
||||
"current_job_id": current_job[:8] if current_job else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Error getting action info for {action_name}: {str(e)}")
|
||||
actions_list[action_name] = {
|
||||
"type_name": "unknown",
|
||||
"action_path": f"/devices/{device_id}/{action_name}",
|
||||
"is_busy": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
return True, {
|
||||
"device_id": device_id,
|
||||
"actions": actions_list,
|
||||
"action_count": len(actions_list),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting device actions: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_action_schema(device_id: str, action_name: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取动作的Schema详情
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
action_name: 动作名称
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, Schema信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
|
||||
result = {
|
||||
"device_id": device_id,
|
||||
"action_name": action_name,
|
||||
"schema": None,
|
||||
"goal_default": None,
|
||||
"action_type": None,
|
||||
"is_busy": False,
|
||||
}
|
||||
|
||||
# 检查动作是否繁忙
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
result["is_busy"] = is_busy
|
||||
result["current_job_id"] = current_job[:8] if current_job else None
|
||||
|
||||
# 方法1: 从 registered_devices 获取运行时信息
|
||||
if device_id in registered_devices:
|
||||
device_info = registered_devices[device_id]
|
||||
base_node = device_info.get("base_node_instance")
|
||||
|
||||
if base_node and hasattr(base_node, "_action_value_mappings"):
|
||||
action_mappings = base_node._action_value_mappings
|
||||
if action_name in action_mappings:
|
||||
mapping = action_mappings[action_name]
|
||||
result["schema"] = mapping.get("schema")
|
||||
result["goal_default"] = mapping.get("goal_default")
|
||||
result["action_type"] = str(mapping.get("type", ""))
|
||||
|
||||
# 方法2: 从 lab_registry 获取注册表信息(如果运行时没有)
|
||||
if result["schema"] is None and lab_registry:
|
||||
# 尝试查找设备类型
|
||||
devices_config = host_node.devices_config
|
||||
device_class = None
|
||||
|
||||
# 从配置中获取设备类型
|
||||
for tree in devices_config.trees:
|
||||
node = tree.root_node
|
||||
if node.res_content.id == device_id:
|
||||
device_class = node.res_content.klass
|
||||
break
|
||||
|
||||
if device_class and device_class in lab_registry.device_type_registry:
|
||||
device_type_info = lab_registry.device_type_registry[device_class]
|
||||
class_info = device_type_info.get("class", {})
|
||||
action_mappings = class_info.get("action_value_mappings", {})
|
||||
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
mapping = action_mappings[key]
|
||||
result["schema"] = mapping.get("schema")
|
||||
result["goal_default"] = mapping.get("goal_default")
|
||||
result["action_type"] = str(mapping.get("type", ""))
|
||||
result["handles"] = mapping.get("handles", {})
|
||||
result["placeholder_keys"] = mapping.get("placeholder_keys", {})
|
||||
break
|
||||
|
||||
if result["schema"] is None:
|
||||
return False, {"error": f"Action schema not found: {device_id}/{action_name}"}
|
||||
|
||||
return True, result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting action schema: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
|
||||
|
||||
def get_all_available_actions() -> Tuple[bool, Dict[str, Any]]:
|
||||
"""获取所有设备的可用动作
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Dict]: (是否成功, 所有设备的动作信息)
|
||||
"""
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node is None:
|
||||
return False, {"error": "Host node not initialized"}
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
from unilabos.app.web.utils.action_utils import get_action_info
|
||||
|
||||
all_actions = {}
|
||||
total_action_count = 0
|
||||
|
||||
for device_id, device_info in registered_devices.items():
|
||||
actions = device_info.get("actions", {})
|
||||
device_actions = {}
|
||||
|
||||
for action_name, action_server in actions.items():
|
||||
try:
|
||||
action_info = get_action_info(action_server, action_name)
|
||||
is_busy, current_job = check_device_action_busy(device_id, action_name)
|
||||
device_actions[action_name] = {
|
||||
"type_name": action_info.get("type_name", ""),
|
||||
"action_path": action_info.get("action_path", ""),
|
||||
"is_busy": is_busy,
|
||||
"current_job_id": current_job[:8] if current_job else None,
|
||||
}
|
||||
total_action_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"[Controller] Error processing action {device_id}/{action_name}: {str(e)}")
|
||||
|
||||
if device_actions:
|
||||
all_actions[device_id] = {
|
||||
"actions": device_actions,
|
||||
"action_count": len(device_actions),
|
||||
"machine_name": host_node.device_machine_names.get(device_id, "未知"),
|
||||
}
|
||||
|
||||
return True, {
|
||||
"devices": all_actions,
|
||||
"device_count": len(all_actions),
|
||||
"total_action_count": total_action_count,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Controller] Error getting all available actions: {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False, {"error": str(e)}
|
||||
@@ -261,29 +261,28 @@ class DeviceActionManager:
|
||||
device_key = job_info.device_action_key
|
||||
|
||||
# 如果是正在执行的任务
|
||||
if (
|
||||
device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id
|
||||
): # 后面需要和cancel_goal进行联动,而不是在这里进行处理,现在默认等待这个job结束
|
||||
# del self.active_jobs[device_key]
|
||||
# job_info.status = JobStatus.ENDED
|
||||
# # 从all_jobs中移除
|
||||
# del self.all_jobs[job_id]
|
||||
# job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
# logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||
# 清理active job状态
|
||||
del self.active_jobs[device_key]
|
||||
job_info.status = JobStatus.ENDED
|
||||
# 从all_jobs中移除
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
|
||||
# # 启动下一个任务
|
||||
# if device_key in self.device_queues and self.device_queues[device_key]:
|
||||
# next_job = self.device_queues[device_key].pop(0)
|
||||
# # 将下一个job设置为READY状态并放入active_jobs
|
||||
# next_job.status = JobStatus.READY
|
||||
# next_job.update_timestamp()
|
||||
# next_job.set_ready_timeout(10)
|
||||
# self.active_jobs[device_key] = next_job
|
||||
# next_job_log = format_job_log(next_job.job_id, next_job.task_id,
|
||||
# next_job.device_id, next_job.action_name)
|
||||
# logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
# return True
|
||||
pass
|
||||
# 启动下一个任务
|
||||
if device_key in self.device_queues and self.device_queues[device_key]:
|
||||
next_job = self.device_queues[device_key].pop(0)
|
||||
# 将下一个job设置为READY状态并放入active_jobs
|
||||
next_job.status = JobStatus.READY
|
||||
next_job.update_timestamp()
|
||||
next_job.set_ready_timeout(10)
|
||||
self.active_jobs[device_key] = next_job
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
return True
|
||||
|
||||
# 如果是排队中的任务
|
||||
elif device_key in self.device_queues:
|
||||
@@ -360,6 +359,7 @@ class MessageProcessor:
|
||||
self.device_manager = device_manager
|
||||
self.queue_processor = None # 延迟设置
|
||||
self.websocket_client = None # 延迟设置
|
||||
self.session_id = ""
|
||||
|
||||
# WebSocket连接
|
||||
self.websocket = None
|
||||
@@ -389,7 +389,7 @@ class MessageProcessor:
|
||||
self.is_running = True
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name="MessageProcessor")
|
||||
self.thread.start()
|
||||
logger.info("[MessageProcessor] Started")
|
||||
logger.trace("[MessageProcessor] Started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止消息处理线程"""
|
||||
@@ -428,14 +428,17 @@ class MessageProcessor:
|
||||
ssl=ssl_context,
|
||||
ping_interval=WSConfig.ping_interval,
|
||||
ping_timeout=10,
|
||||
additional_headers={"Authorization": f"Lab {BasicConfig.auth_secret()}"},
|
||||
additional_headers={
|
||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||
"EdgeSession": f"{self.session_id}",
|
||||
},
|
||||
logger=ws_logger,
|
||||
) as websocket:
|
||||
self.websocket = websocket
|
||||
self.connected = True
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
|
||||
# 启动发送协程
|
||||
send_task = asyncio.create_task(self._send_handler())
|
||||
@@ -500,7 +503,7 @@ class MessageProcessor:
|
||||
|
||||
async def _send_handler(self):
|
||||
"""处理发送队列中的消息"""
|
||||
logger.debug("[MessageProcessor] Send handler started")
|
||||
logger.trace("[MessageProcessor] Send handler started")
|
||||
|
||||
try:
|
||||
while self.connected and self.websocket:
|
||||
@@ -573,6 +576,9 @@ class MessageProcessor:
|
||||
await self._handle_resource_tree_update(message_data, "update")
|
||||
elif message_type == "remove_material":
|
||||
await self._handle_resource_tree_update(message_data, "remove")
|
||||
elif message_type == "session_id":
|
||||
self.session_id = message_data.get("session_id")
|
||||
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
else:
|
||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||
|
||||
@@ -741,31 +747,51 @@ class MessageProcessor:
|
||||
job_info.action_name if job_info else "",
|
||||
)
|
||||
|
||||
# 按job_id取消单个job
|
||||
# 先通知HostNode取消ROS2 action(如果存在)
|
||||
host_node = HostNode.get_instance(0)
|
||||
ros_cancel_success = False
|
||||
if host_node:
|
||||
ros_cancel_success = host_node.cancel_goal(job_id)
|
||||
if ros_cancel_success:
|
||||
logger.info(f"[MessageProcessor] ROS2 cancel request sent for job {job_log}")
|
||||
else:
|
||||
logger.debug(
|
||||
f"[MessageProcessor] Job {job_log} not in ROS2 goals " "(may be queued or already finished)"
|
||||
)
|
||||
|
||||
# 按job_id取消单个job(清理状态机)
|
||||
success = self.device_manager.cancel_job(job_id)
|
||||
if success:
|
||||
# 通知HostNode取消
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node:
|
||||
host_node.cancel_goal(job_id)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} cancelled")
|
||||
logger.info(f"[MessageProcessor] Job {job_log} cancelled from queue/active list")
|
||||
|
||||
# 通知QueueProcessor有队列更新
|
||||
if self.queue_processor:
|
||||
self.queue_processor.notify_queue_update()
|
||||
else:
|
||||
logger.warning(f"[MessageProcessor] Failed to cancel job {job_log}")
|
||||
logger.warning(f"[MessageProcessor] Failed to cancel job {job_log} from queue")
|
||||
|
||||
elif task_id:
|
||||
# 按task_id取消所有相关job
|
||||
# 先通知HostNode取消所有ROS2 actions
|
||||
# 需要先获取所有相关job_ids
|
||||
jobs_to_cancel = []
|
||||
with self.device_manager.lock:
|
||||
jobs_to_cancel = [
|
||||
job_info for job_info in self.device_manager.all_jobs.values() if job_info.task_id == task_id
|
||||
]
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node and jobs_to_cancel:
|
||||
ros_cancelled_count = 0
|
||||
for job_info in jobs_to_cancel:
|
||||
if host_node.cancel_goal(job_info.job_id):
|
||||
ros_cancelled_count += 1
|
||||
logger.info(
|
||||
f"[MessageProcessor] Sent ROS2 cancel for " f"{ros_cancelled_count}/{len(jobs_to_cancel)} jobs"
|
||||
)
|
||||
|
||||
# 按task_id取消所有相关job(清理状态机)
|
||||
cancelled_job_ids = self.device_manager.cancel_jobs_by_task_id(task_id)
|
||||
if cancelled_job_ids:
|
||||
# 通知HostNode取消所有job
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node:
|
||||
for cancelled_job_id in cancelled_job_ids:
|
||||
host_node.cancel_goal(cancelled_job_id)
|
||||
|
||||
logger.info(f"[MessageProcessor] Cancelled {len(cancelled_job_ids)} jobs for task_id: {task_id}")
|
||||
|
||||
# 通知QueueProcessor有队列更新
|
||||
@@ -913,7 +939,7 @@ class QueueProcessor:
|
||||
# 事件通知机制
|
||||
self.queue_update_event = threading.Event()
|
||||
|
||||
logger.info("[QueueProcessor] Initialized")
|
||||
logger.trace("[QueueProcessor] Initialized")
|
||||
|
||||
def set_websocket_client(self, websocket_client: "WebSocketClient"):
|
||||
"""设置WebSocket客户端引用"""
|
||||
@@ -928,7 +954,7 @@ class QueueProcessor:
|
||||
self.is_running = True
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name="QueueProcessor")
|
||||
self.thread.start()
|
||||
logger.info("[QueueProcessor] Started")
|
||||
logger.trace("[QueueProcessor] Started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止队列处理线程"""
|
||||
@@ -939,7 +965,7 @@ class QueueProcessor:
|
||||
|
||||
def _run(self):
|
||||
"""运行队列处理主循环"""
|
||||
logger.debug("[QueueProcessor] Queue processor started")
|
||||
logger.trace("[QueueProcessor] Queue processor started")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
@@ -1056,11 +1082,19 @@ class QueueProcessor:
|
||||
"""处理任务完成"""
|
||||
# 获取job信息用于日志
|
||||
job_info = self.device_manager.get_job_info(job_id)
|
||||
|
||||
# 如果job不存在,说明可能已被手动取消
|
||||
if not job_info:
|
||||
logger.debug(
|
||||
f"[QueueProcessor] Job {job_id[:8]} not found in manager " "(may have been cancelled manually)"
|
||||
)
|
||||
return
|
||||
|
||||
job_log = format_job_log(
|
||||
job_id,
|
||||
job_info.task_id if job_info else "",
|
||||
job_info.device_id if job_info else "",
|
||||
job_info.action_name if job_info else "",
|
||||
job_info.task_id,
|
||||
job_info.device_id,
|
||||
job_info.action_name,
|
||||
)
|
||||
|
||||
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
@@ -1141,7 +1175,6 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
else:
|
||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||
|
||||
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||
return url
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -1154,13 +1187,11 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||
return
|
||||
|
||||
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||
|
||||
# 启动两个核心线程
|
||||
self.message_processor.start()
|
||||
self.queue_processor.start()
|
||||
|
||||
logger.info("[WebSocketClient] All threads started")
|
||||
logger.trace("[WebSocketClient] All threads started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止WebSocket客户端"""
|
||||
@@ -1169,6 +1200,18 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
|
||||
logger.info("[WebSocketClient] Stopping connection")
|
||||
|
||||
# 发送 normal_exit 消息
|
||||
if self.is_connected():
|
||||
try:
|
||||
session_id = self.message_processor.session_id
|
||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||
# 给一点时间让消息发送出去
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||
|
||||
# 停止两个核心线程
|
||||
self.message_processor.stop()
|
||||
self.queue_processor.stop()
|
||||
@@ -1268,3 +1311,19 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.info(f"[WebSocketClient] Job {job_log} cancelled successfully")
|
||||
else:
|
||||
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
|
||||
|
||||
def publish_host_ready(self) -> None:
|
||||
"""发布host_node ready信号"""
|
||||
if self.is_disabled or not self.is_connected():
|
||||
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
|
||||
return
|
||||
|
||||
message = {
|
||||
"action": "host_node_ready",
|
||||
"data": {
|
||||
"status": "ready",
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info("[WebSocketClient] Host node ready signal published")
|
||||
|
||||
@@ -18,7 +18,11 @@ class BasicConfig:
|
||||
vis_2d_enable = False
|
||||
enable_resource_load = True
|
||||
communication_protocol = "websocket"
|
||||
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@classmethod
|
||||
def auth_secret(cls):
|
||||
@@ -36,18 +40,9 @@ class WSConfig:
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
|
||||
# OSS上传配置
|
||||
class OSSUploadConfig:
|
||||
api_host = ""
|
||||
authorization = ""
|
||||
init_endpoint = ""
|
||||
complete_endpoint = ""
|
||||
max_retries = 3
|
||||
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
||||
|
||||
|
||||
# ROS配置
|
||||
@@ -71,13 +66,14 @@ def _update_config_from_module(module):
|
||||
if not attr.startswith("_"):
|
||||
setattr(obj, attr, getattr(getattr(module, name), attr))
|
||||
|
||||
|
||||
def _update_config_from_env():
|
||||
prefix = "UNILABOS_"
|
||||
for env_key, env_value in os.environ.items():
|
||||
if not env_key.startswith(prefix):
|
||||
continue
|
||||
try:
|
||||
key_path = env_key[len(prefix):] # Remove UNILAB_ prefix
|
||||
key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix
|
||||
class_field = key_path.upper().split("_", 1)
|
||||
if len(class_field) != 2:
|
||||
logger.warning(f"[ENV] 环境变量格式不正确:{env_key}")
|
||||
|
||||
19
unilabos/device_comms/opcua_client/README.md
Normal file
19
unilabos/device_comms/opcua_client/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# OPC UA 通用客户端
|
||||
|
||||
本模块提供了一个通用的 OPC UA 客户端实现,可以通过外部配置(CSV文件)来定义节点,并通过JSON配置来执行工作流。
|
||||
|
||||
## 特点
|
||||
|
||||
- 支持通过 CSV 文件配置 OPC UA 节点(只需提供名称、类型和数据类型,支持节点为中文名,需指定NodeLanguage)
|
||||
- 自动查找服务器中的节点,无需知道确切的节点ID
|
||||
- 提供工作流机制
|
||||
- 支持通过 JSON 配置创建工作流
|
||||
|
||||
## 使用方法
|
||||
|
||||
step1: 准备opcua_nodes.csv文件
|
||||
step2: 编写opcua_workflow_example.json,以定义工作流。指定opcua_nodes.csv
|
||||
step3: 编写工作流对应action
|
||||
step4: 编写opcua_example.yaml注册表
|
||||
step5: 编写opcua_example.json组态图。指定opcua_workflow_example.json定义工作流文件
|
||||
|
||||
9
unilabos/device_comms/opcua_client/__init__.py
Normal file
9
unilabos/device_comms/opcua_client/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from unilabos.device_comms.opcua_client.node.uniopcua import Variable, Method, Object, NodeType, DataType
|
||||
|
||||
__all__ = [
|
||||
'Variable',
|
||||
'Method',
|
||||
'Object',
|
||||
'NodeType',
|
||||
'DataType',
|
||||
]
|
||||
1380
unilabos/device_comms/opcua_client/client.py
Normal file
1380
unilabos/device_comms/opcua_client/client.py
Normal file
File diff suppressed because it is too large
Load Diff
10
unilabos/device_comms/opcua_client/node/__init__.py
Normal file
10
unilabos/device_comms/opcua_client/node/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from unilabos.device_comms.opcua_client.node.uniopcua import Variable, Method, Object, NodeType, DataType, Base
|
||||
|
||||
__all__ = [
|
||||
'Variable',
|
||||
'Method',
|
||||
'Object',
|
||||
'NodeType',
|
||||
'DataType',
|
||||
'Base',
|
||||
]
|
||||
180
unilabos/device_comms/opcua_client/node/uniopcua.py
Normal file
180
unilabos/device_comms/opcua_client/node/uniopcua.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# coding=utf-8
|
||||
from enum import Enum
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple, Union, Optional, Any, List
|
||||
|
||||
from opcua import Client, Node
|
||||
from opcua.ua import NodeId, NodeClass, VariantType
|
||||
|
||||
|
||||
class DataType(Enum):
|
||||
BOOLEAN = VariantType.Boolean
|
||||
SBYTE = VariantType.SByte
|
||||
BYTE = VariantType.Byte
|
||||
INT16 = VariantType.Int16
|
||||
UINT16 = VariantType.UInt16
|
||||
INT32 = VariantType.Int32
|
||||
UINT32 = VariantType.UInt32
|
||||
INT64 = VariantType.Int64
|
||||
UINT64 = VariantType.UInt64
|
||||
FLOAT = VariantType.Float
|
||||
DOUBLE = VariantType.Double
|
||||
STRING = VariantType.String
|
||||
DATETIME = VariantType.DateTime
|
||||
BYTESTRING = VariantType.ByteString
|
||||
|
||||
|
||||
class NodeType(Enum):
|
||||
VARIABLE = NodeClass.Variable
|
||||
OBJECT = NodeClass.Object
|
||||
METHOD = NodeClass.Method
|
||||
OBJECTTYPE = NodeClass.ObjectType
|
||||
VARIABLETYPE = NodeClass.VariableType
|
||||
REFERENCETYPE = NodeClass.ReferenceType
|
||||
DATATYPE = NodeClass.DataType
|
||||
VIEW = NodeClass.View
|
||||
|
||||
|
||||
class Base(ABC):
|
||||
def __init__(self, client: Client, name: str, node_id: str, typ: NodeType, data_type: DataType):
|
||||
self._node_id: str = node_id
|
||||
self._client = client
|
||||
self._name = name
|
||||
self._type = typ
|
||||
self._data_type = data_type
|
||||
self._node: Optional[Node] = None
|
||||
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||
if "NumericNodeId" in self._node_id:
|
||||
# 从字符串中提取命名空间和标识符
|
||||
import re
|
||||
match = re.search(r'ns=(\d+);i=(\d+)', self._node_id)
|
||||
if match:
|
||||
ns = int(match.group(1))
|
||||
identifier = int(match.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._node = self._client.get_node(node_id)
|
||||
else:
|
||||
raise ValueError(f"无法解析节点ID: {self._node_id}")
|
||||
else:
|
||||
# 直接使用节点ID字符串
|
||||
self._node = self._client.get_node(self._node_id)
|
||||
except Exception as e:
|
||||
print(f"获取节点失败: {self._node_id}, 错误: {e}")
|
||||
raise
|
||||
return self._node
|
||||
|
||||
@abstractmethod
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""读取节点值,返回(值, 是否出错)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def write(self, value: Any) -> bool:
|
||||
"""写入节点值,返回是否出错"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def type(self) -> NodeType:
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
return self._node_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
|
||||
class Variable(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str, data_type: DataType):
|
||||
super().__init__(client, name, node_id, NodeType.VARIABLE, data_type)
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
try:
|
||||
value = self._get_node().get_value()
|
||||
return value, False
|
||||
except Exception as e:
|
||||
print(f"读取变量 {self._name} 失败: {e}")
|
||||
return None, True
|
||||
|
||||
def write(self, value: Any) -> bool:
|
||||
try:
|
||||
self._get_node().set_value(value)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"写入变量 {self._name} 失败: {e}")
|
||||
return True
|
||||
|
||||
|
||||
class Method(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str, parent_node_id: str, data_type: DataType):
|
||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||
self._parent_node_id = parent_node_id
|
||||
self._parent_node = None
|
||||
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||
if "NumericNodeId" in self._parent_node_id:
|
||||
# 从字符串中提取命名空间和标识符
|
||||
import re
|
||||
match = re.search(r'ns=(\d+);i=(\d+)', self._parent_node_id)
|
||||
if match:
|
||||
ns = int(match.group(1))
|
||||
identifier = int(match.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._parent_node = self._client.get_node(node_id)
|
||||
else:
|
||||
raise ValueError(f"无法解析父节点ID: {self._parent_node_id}")
|
||||
else:
|
||||
# 直接使用节点ID字符串
|
||||
self._parent_node = self._client.get_node(self._parent_node_id)
|
||||
except Exception as e:
|
||||
print(f"获取父节点失败: {self._parent_node_id}, 错误: {e}")
|
||||
raise
|
||||
return self._parent_node
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""方法节点不支持读取操作"""
|
||||
return None, True
|
||||
|
||||
def write(self, value: Any) -> bool:
|
||||
"""方法节点不支持写入操作"""
|
||||
return True
|
||||
|
||||
def call(self, *args) -> Tuple[Any, bool]:
|
||||
"""调用方法,返回(返回值, 是否出错)"""
|
||||
try:
|
||||
result = self._get_parent_node().call_method(self._get_node(), *args)
|
||||
return result, False
|
||||
except Exception as e:
|
||||
print(f"调用方法 {self._name} 失败: {e}")
|
||||
return None, True
|
||||
|
||||
|
||||
class Object(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str):
|
||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""对象节点不支持直接读取操作"""
|
||||
return None, True
|
||||
|
||||
def write(self, value: Any) -> bool:
|
||||
"""对象节点不支持直接写入操作"""
|
||||
return True
|
||||
|
||||
def get_children(self) -> Tuple[List[Node], bool]:
|
||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||
try:
|
||||
children = self._get_node().get_children()
|
||||
return children, False
|
||||
except Exception as e:
|
||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||
return [], True
|
||||
98
unilabos/device_comms/opcua_client/opcua_config.json
Normal file
98
unilabos/device_comms/opcua_client/opcua_config.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"register_node_list_from_csv_path": {
|
||||
"path": "simple_opcua_nodes.csv"
|
||||
},
|
||||
"create_flow": [
|
||||
{
|
||||
"name": "温度控制流程",
|
||||
"action": [
|
||||
{
|
||||
"name": "温度控制动作",
|
||||
"node_function_to_create": [
|
||||
{
|
||||
"func_name": "read_temperature",
|
||||
"node_name": "Temperature",
|
||||
"mode": "read"
|
||||
},
|
||||
{
|
||||
"func_name": "read_heating_status",
|
||||
"node_name": "HeatingStatus",
|
||||
"mode": "read"
|
||||
},
|
||||
{
|
||||
"func_name": "set_heating",
|
||||
"node_name": "HeatingEnabled",
|
||||
"mode": "write",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"create_init_function": {
|
||||
"func_name": "init_setpoint",
|
||||
"node_name": "Setpoint",
|
||||
"mode": "write",
|
||||
"value": 25.0
|
||||
},
|
||||
"create_start_function": {
|
||||
"func_name": "start_heating_control",
|
||||
"node_name": "HeatingEnabled",
|
||||
"mode": "write",
|
||||
"write_functions": [
|
||||
"set_heating"
|
||||
],
|
||||
"condition_functions": [
|
||||
"read_temperature",
|
||||
"read_heating_status"
|
||||
],
|
||||
"stop_condition_expression": "read_temperature >= 25.0 and read_heating_status"
|
||||
},
|
||||
"create_stop_function": {
|
||||
"func_name": "stop_heating",
|
||||
"node_name": "HeatingEnabled",
|
||||
"mode": "write",
|
||||
"value": false
|
||||
},
|
||||
"create_cleanup_function": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "报警重置流程",
|
||||
"action": [
|
||||
{
|
||||
"name": "报警重置动作",
|
||||
"node_function_to_create": [
|
||||
{
|
||||
"func_name": "reset_alarm",
|
||||
"node_name": "ResetAlarm",
|
||||
"mode": "call",
|
||||
"value": []
|
||||
}
|
||||
],
|
||||
"create_init_function": null,
|
||||
"create_start_function": {
|
||||
"func_name": "start_reset_alarm",
|
||||
"node_name": "ResetAlarm",
|
||||
"mode": "call",
|
||||
"write_functions": [],
|
||||
"condition_functions": [
|
||||
"reset_alarm"
|
||||
],
|
||||
"stop_condition_expression": "True"
|
||||
},
|
||||
"create_stop_function": null,
|
||||
"create_cleanup_function": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "完整控制流程",
|
||||
"action": [
|
||||
"温度控制流程",
|
||||
"报警重置流程"
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute_flow": [
|
||||
"完整控制流程"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
Name,EnglishName,NodeType,DataType,NodeLanguage
|
||||
中文名,EnglishName,VARIABLE,INT32,Chinese
|
||||
|
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"register_node_list_from_csv_path": {
|
||||
"path": "opcua_nodes_example.csv"
|
||||
},
|
||||
"create_flow": [
|
||||
{
|
||||
"name": "name",
|
||||
"description": "description",
|
||||
"parameters": ["parameter1", "parameter2"],
|
||||
"action": [
|
||||
{
|
||||
"init_function": {
|
||||
"func_name": "init_grab_params",
|
||||
"write_nodes": ["parameter1", "parameter2"]
|
||||
},
|
||||
"start_function": {
|
||||
"func_name": "start_grab",
|
||||
"write_nodes": {"parameter_start": true},
|
||||
"condition_nodes": ["parameter_condition"],
|
||||
"stop_condition_expression": "parameter_condition == True"
|
||||
},
|
||||
"stop_function": {
|
||||
"func_name": "stop_grab",
|
||||
"write_nodes": {"parameter_start": false}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
311
unilabos/device_comms/opcua_client/server.py
Normal file
311
unilabos/device_comms/opcua_client/server.py
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OPC UA测试服务器
|
||||
用于测试OPC UA客户端功能,特别是temperature_control和valve_control工作流
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from opcua import Server, ua
|
||||
import threading
|
||||
|
||||
# 设置日志
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OpcUaTestServer:
|
||||
"""OPC UA测试服务器类"""
|
||||
|
||||
def __init__(self, endpoint="opc.tcp://localhost:4840/freeopcua/server/"):
|
||||
"""
|
||||
初始化OPC UA服务器
|
||||
|
||||
Args:
|
||||
endpoint: 服务器端点URL
|
||||
"""
|
||||
self.server = Server()
|
||||
self.server.set_endpoint(endpoint)
|
||||
|
||||
# 设置服务器名称
|
||||
self.server.set_server_name("UniLabOS OPC UA Test Server")
|
||||
|
||||
# 设置服务器命名空间
|
||||
self.idx = self.server.register_namespace("http://unilabos.com/opcua/test")
|
||||
|
||||
# 获取Objects节点
|
||||
self.objects = self.server.get_objects_node()
|
||||
|
||||
# 创建设备对象
|
||||
self.device = self.objects.add_object(self.idx, "TestDevice")
|
||||
|
||||
# 存储所有节点的字典
|
||||
self.nodes = {}
|
||||
|
||||
# 初始化标志
|
||||
self.running = False
|
||||
|
||||
# 控制标志
|
||||
self.simulation_active = True
|
||||
|
||||
def add_variable(self, name, value, data_type=None):
|
||||
"""
|
||||
添加变量节点
|
||||
|
||||
Args:
|
||||
name: 变量名称
|
||||
value: 初始值
|
||||
data_type: 数据类型 (可选)
|
||||
"""
|
||||
if data_type is None:
|
||||
var = self.device.add_variable(self.idx, name, value)
|
||||
else:
|
||||
var = self.device.add_variable(self.idx, name, value, data_type)
|
||||
|
||||
# 设置变量可写
|
||||
var.set_writable()
|
||||
|
||||
# 存储节点
|
||||
self.nodes[name] = var
|
||||
logger.info(f"添加变量节点: {name}, 初始值: {value}")
|
||||
return var
|
||||
|
||||
def add_method(self, name, callback, inputs=None, outputs=None):
|
||||
"""
|
||||
添加方法节点
|
||||
|
||||
Args:
|
||||
name: 方法名称
|
||||
callback: 回调函数
|
||||
inputs: 输入参数列表 [(name, type), ...]
|
||||
outputs: 输出参数列表 [(name, type), ...]
|
||||
"""
|
||||
if inputs is None:
|
||||
inputs = []
|
||||
if outputs is None:
|
||||
outputs = []
|
||||
|
||||
# 创建输入参数
|
||||
input_args = []
|
||||
for arg_name, arg_type in inputs:
|
||||
input_args.append(ua.Argument())
|
||||
input_args[-1].Name = arg_name
|
||||
input_args[-1].DataType = arg_type
|
||||
input_args[-1].ValueRank = -1
|
||||
|
||||
# 创建输出参数
|
||||
output_args = []
|
||||
for arg_name, arg_type in outputs:
|
||||
output_args.append(ua.Argument())
|
||||
output_args[-1].Name = arg_name
|
||||
output_args[-1].DataType = arg_type
|
||||
output_args[-1].ValueRank = -1
|
||||
|
||||
# 添加方法
|
||||
method = self.device.add_method(
|
||||
self.idx,
|
||||
name,
|
||||
callback,
|
||||
input_args,
|
||||
output_args
|
||||
)
|
||||
|
||||
# 存储节点
|
||||
self.nodes[name] = method
|
||||
logger.info(f"添加方法节点: {name}")
|
||||
return method
|
||||
|
||||
def start(self):
|
||||
"""启动服务器"""
|
||||
if not self.running:
|
||||
self.server.start()
|
||||
self.running = True
|
||||
logger.info("OPC UA服务器已启动")
|
||||
|
||||
# 启动模拟线程
|
||||
self.simulation_thread = threading.Thread(target=self.run_simulation)
|
||||
self.simulation_thread.daemon = True
|
||||
self.simulation_thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""停止服务器"""
|
||||
if self.running:
|
||||
self.simulation_active = False
|
||||
if hasattr(self, 'simulation_thread'):
|
||||
self.simulation_thread.join(timeout=2)
|
||||
self.server.stop()
|
||||
self.running = False
|
||||
logger.info("OPC UA服务器已停止")
|
||||
|
||||
def get_node(self, name):
|
||||
"""获取节点"""
|
||||
if name in self.nodes:
|
||||
return self.nodes[name]
|
||||
return None
|
||||
|
||||
def update_variable(self, name, value):
|
||||
"""更新变量值"""
|
||||
if name in self.nodes:
|
||||
self.nodes[name].set_value(value)
|
||||
logger.debug(f"更新变量 {name} = {value}")
|
||||
return True
|
||||
logger.warning(f"变量 {name} 不存在")
|
||||
return False
|
||||
|
||||
def run_simulation(self):
|
||||
"""运行模拟线程"""
|
||||
logger.info("启动模拟线程")
|
||||
|
||||
temp = 20.0
|
||||
valve_position = 0.0
|
||||
flow_rate = 0.0
|
||||
|
||||
while self.simulation_active and self.running:
|
||||
try:
|
||||
# 温度控制模拟
|
||||
heating_enabled = self.get_node("HeatingEnabled").get_value()
|
||||
setpoint = self.get_node("Setpoint").get_value()
|
||||
|
||||
if heating_enabled:
|
||||
self.update_variable("HeatingStatus", True)
|
||||
if temp < setpoint:
|
||||
temp += 0.5 # 加快温度上升速度
|
||||
else:
|
||||
temp -= 0.1
|
||||
else:
|
||||
self.update_variable("HeatingStatus", False)
|
||||
if temp > 20.0:
|
||||
temp -= 0.2
|
||||
|
||||
# 更新温度
|
||||
self.update_variable("Temperature", round(temp, 2))
|
||||
|
||||
# 阀门控制模拟
|
||||
valve_control = self.get_node("ValveControl").get_value()
|
||||
valve_setpoint = self.get_node("ValveSetpoint").get_value()
|
||||
|
||||
if valve_control:
|
||||
if valve_position < valve_setpoint:
|
||||
valve_position += 5.0 # 加快阀门开启速度
|
||||
if valve_position > valve_setpoint:
|
||||
valve_position = valve_setpoint
|
||||
else:
|
||||
valve_position -= 1.0
|
||||
if valve_position < 0:
|
||||
valve_position = 0
|
||||
else:
|
||||
if valve_position > 0:
|
||||
valve_position -= 5.0
|
||||
if valve_position < 0:
|
||||
valve_position = 0
|
||||
|
||||
# 更新阀门位置
|
||||
self.update_variable("ValvePosition", round(valve_position, 2))
|
||||
|
||||
# 流量模拟 - 与阀门位置成正比
|
||||
flow_rate = valve_position * 0.2 # 简单线性关系
|
||||
self.update_variable("FlowRate", round(flow_rate, 2))
|
||||
|
||||
# 更新系统状态
|
||||
status = []
|
||||
if heating_enabled:
|
||||
status.append("Heating")
|
||||
if valve_control:
|
||||
status.append("Valve_Open")
|
||||
|
||||
if status:
|
||||
self.update_variable("SystemStatus", "_".join(status))
|
||||
else:
|
||||
self.update_variable("SystemStatus", "Idle")
|
||||
|
||||
# 每200毫秒更新一次
|
||||
time.sleep(0.2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模拟线程错误: {e}")
|
||||
time.sleep(1) # 出错时稍等一会再继续
|
||||
|
||||
logger.info("模拟线程已停止")
|
||||
|
||||
def reset_alarm_callback(parent, *args):
|
||||
"""重置报警的回调函数"""
|
||||
logger.info("调用了重置报警方法")
|
||||
return True
|
||||
|
||||
def start_process_callback(parent, *args):
|
||||
"""启动流程的回调函数"""
|
||||
process_id = args[0] if args else 0
|
||||
logger.info(f"启动流程 ID: {process_id}")
|
||||
return process_id
|
||||
|
||||
def stop_process_callback(parent, *args):
|
||||
"""停止流程的回调函数"""
|
||||
process_id = args[0] if args else 0
|
||||
logger.info(f"停止流程 ID: {process_id}")
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
try:
|
||||
# 创建服务器
|
||||
server = OpcUaTestServer()
|
||||
|
||||
# 添加变量节点 - 温度控制相关
|
||||
server.add_variable("Temperature", 20.0, ua.VariantType.Float)
|
||||
server.add_variable("Setpoint", 22.0, ua.VariantType.Float)
|
||||
server.add_variable("HeatingEnabled", False, ua.VariantType.Boolean)
|
||||
server.add_variable("HeatingStatus", False, ua.VariantType.Boolean)
|
||||
|
||||
# 添加变量节点 - 阀门控制相关
|
||||
server.add_variable("ValvePosition", 0.0, ua.VariantType.Float)
|
||||
server.add_variable("ValveSetpoint", 0.0, ua.VariantType.Float)
|
||||
server.add_variable("ValveControl", False, ua.VariantType.Boolean)
|
||||
server.add_variable("FlowRate", 0.0, ua.VariantType.Float)
|
||||
|
||||
# 其他状态变量
|
||||
server.add_variable("SystemStatus", "Idle", ua.VariantType.String)
|
||||
|
||||
# 添加方法节点
|
||||
server.add_method(
|
||||
"ResetAlarm",
|
||||
reset_alarm_callback,
|
||||
[],
|
||||
[("Result", ua.VariantType.Boolean)]
|
||||
)
|
||||
|
||||
server.add_method(
|
||||
"StartProcess",
|
||||
start_process_callback,
|
||||
[("ProcessId", ua.VariantType.Int32)],
|
||||
[("Result", ua.VariantType.Int32)]
|
||||
)
|
||||
|
||||
server.add_method(
|
||||
"StopProcess",
|
||||
stop_process_callback,
|
||||
[("ProcessId", ua.VariantType.Int32)],
|
||||
[("Result", ua.VariantType.Boolean)]
|
||||
)
|
||||
|
||||
# 启动服务器
|
||||
server.start()
|
||||
logger.info("服务器已启动,按Ctrl+C停止")
|
||||
|
||||
# 保持服务器运行
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到键盘中断,正在停止服务器...")
|
||||
|
||||
# 停止服务器
|
||||
server.stop()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"服务器错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
dummy2_robot:
|
||||
kinematics:
|
||||
# DH parameters for Dummy2 6-DOF robot arm
|
||||
# [theta, d, a, alpha] for each joint
|
||||
joint_1: [0.0, 0.1, 0.0, 1.5708] # Base rotation
|
||||
joint_2: [0.0, 0.0, 0.2, 0.0] # Shoulder
|
||||
joint_3: [0.0, 0.0, 0.15, 0.0] # Elbow
|
||||
joint_4: [0.0, 0.1, 0.0, 1.5708] # Wrist roll
|
||||
joint_5: [0.0, 0.0, 0.0, -1.5708] # Wrist pitch
|
||||
joint_6: [0.0, 0.06, 0.0, 0.0] # Wrist yaw
|
||||
|
||||
# Tool center point offset from last joint
|
||||
tcp_offset:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.04
|
||||
|
||||
# Workspace limits
|
||||
workspace:
|
||||
x_min: -0.5
|
||||
x_max: 0.5
|
||||
y_min: -0.5
|
||||
y_max: 0.5
|
||||
z_min: 0.0
|
||||
z_max: 0.6
|
||||
45
unilabos/device_mesh/devices/dummy2_robot/config/dummy2.srdf
Normal file
45
unilabos/device_mesh/devices/dummy2_robot/config/dummy2.srdf
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--This does not replace URDF, and is not an extension of URDF.
|
||||
This is a format for representing semantic information about the robot structure.
|
||||
A URDF file must exist for this robot as well, where the joints and the links that are referenced are defined
|
||||
-->
|
||||
<robot name="dummy2">
|
||||
<!--GROUPS: Representation of a set of joints and links. This can be useful for specifying DOF to plan for, defining arms, end effectors, etc-->
|
||||
<!--LINKS: When a link is specified, the parent joint of that link (if it exists) is automatically included-->
|
||||
<!--JOINTS: When a joint is specified, the child link of that joint (which will always exist) is automatically included-->
|
||||
<!--CHAINS: When a chain is specified, all the links along the chain (including endpoints) are included in the group. Additionally, all the joints that are parents to included links are also included. This means that joints along the chain and the parent joint of the base link are included in the group-->
|
||||
<!--SUBGROUPS: Groups can also be formed by referencing to already defined group names-->
|
||||
<group name="dummy2_arm">
|
||||
<joint name="virtual_joint"/>
|
||||
<joint name="Joint1"/>
|
||||
<joint name="Joint2"/>
|
||||
<joint name="Joint3"/>
|
||||
<joint name="Joint4"/>
|
||||
<joint name="Joint5"/>
|
||||
<joint name="Joint6"/>
|
||||
</group>
|
||||
<!--GROUP STATES: Purpose: Define a named state for a particular group, in terms of joint values. This is useful to define states like 'folded arms'-->
|
||||
<group_state name="home" group="dummy2_arm">
|
||||
<joint name="Joint1" value="0"/>
|
||||
<joint name="Joint2" value="0"/>
|
||||
<joint name="Joint3" value="0"/>
|
||||
<joint name="Joint4" value="0"/>
|
||||
<joint name="Joint5" value="0"/>
|
||||
<joint name="Joint6" value="0"/>
|
||||
</group_state>
|
||||
<!--VIRTUAL JOINT: Purpose: this element defines a virtual joint between a robot link and an external frame of reference (considered fixed with respect to the robot)-->
|
||||
<virtual_joint name="virtual_joint" type="fixed" parent_frame="world" child_link="base_link"/>
|
||||
<!--DISABLE COLLISIONS: By default it is assumed that any link of the robot could potentially come into collision with any other link in the robot. This tag disables collision checking between a specified pair of links. -->
|
||||
<disable_collisions link1="J1_1" link2="J2_1" reason="Adjacent"/>
|
||||
<disable_collisions link1="J1_1" link2="J3_1" reason="Never"/>
|
||||
<disable_collisions link1="J1_1" link2="J4_1" reason="Never"/>
|
||||
<disable_collisions link1="J1_1" link2="base_link" reason="Adjacent"/>
|
||||
<disable_collisions link1="J2_1" link2="J3_1" reason="Adjacent"/>
|
||||
<disable_collisions link1="J3_1" link2="J4_1" reason="Adjacent"/>
|
||||
<disable_collisions link1="J3_1" link2="J5_1" reason="Never"/>
|
||||
<disable_collisions link1="J3_1" link2="J6_1" reason="Never"/>
|
||||
<disable_collisions link1="J3_1" link2="base_link" reason="Never"/>
|
||||
<disable_collisions link1="J4_1" link2="J5_1" reason="Adjacent"/>
|
||||
<disable_collisions link1="J4_1" link2="J6_1" reason="Never"/>
|
||||
<disable_collisions link1="J5_1" link2="J6_1" reason="Adjacent"/>
|
||||
</robot>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot name="dummy2" xmlns:xacro="http://www.ros.org/wiki/xacro" >
|
||||
|
||||
<transmission name="Joint1_tran">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="Joint1">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="Joint1_actr">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="Joint2_tran">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="Joint2">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="Joint2_actr">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="Joint3_tran">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="Joint3">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="Joint3_actr">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="Joint4_tran">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="Joint4">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="Joint4_actr">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="Joint5_tran">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="Joint5">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="Joint5_actr">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="Joint6_tran">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="Joint6">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="Joint6_actr">
|
||||
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
</robot>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0"?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="dummy2">
|
||||
<xacro:arg name="initial_positions_file" default="initial_positions.yaml" />
|
||||
|
||||
<!-- Import dummy2 urdf file -->
|
||||
<xacro:include filename="$(find dummy2_description)/urdf/dummy2.xacro" />
|
||||
|
||||
<!-- Import control_xacro -->
|
||||
<xacro:include filename="dummy2.ros2_control.xacro" />
|
||||
|
||||
|
||||
<xacro:dummy2_ros2_control name="FakeSystem" initial_positions_file="$(arg initial_positions_file)"/>
|
||||
|
||||
</robot>
|
||||
@@ -0,0 +1,73 @@
|
||||
###############################################
|
||||
# Modify all parameters related to servoing here
|
||||
###############################################
|
||||
# adapt to dummy2 by Muzhxiaowen, check out the details on bilibili.com
|
||||
|
||||
use_gazebo: false # Whether the robot is started in a Gazebo simulation environment
|
||||
|
||||
## Properties of incoming commands
|
||||
command_in_type: "unitless" # "unitless"> in the range [-1:1], as if from joystick. "speed_units"> cmds are in m/s and rad/s
|
||||
scale:
|
||||
# Scale parameters are only used if command_in_type=="unitless"
|
||||
linear: 0.4 # Max linear velocity. Unit is [m/s]. Only used for Cartesian commands.
|
||||
rotational: 0.8 # Max angular velocity. Unit is [rad/s]. Only used for Cartesian commands.
|
||||
# Max joint angular/linear velocity. Only used for joint commands on joint_command_in_topic.
|
||||
joint: 0.5
|
||||
|
||||
# Optionally override Servo's internal velocity scaling when near singularity or collision (0.0 = use internal velocity scaling)
|
||||
# override_velocity_scaling_factor = 0.0 # valid range [0.0:1.0]
|
||||
|
||||
## Properties of outgoing commands
|
||||
publish_period: 0.034 # 1/Nominal publish rate [seconds]
|
||||
low_latency_mode: false # Set this to true to publish as soon as an incoming Twist command is received (publish_period is ignored)
|
||||
|
||||
# What type of topic does your robot driver expect?
|
||||
# Currently supported are std_msgs/Float64MultiArray or trajectory_msgs/JointTrajectory
|
||||
command_out_type: trajectory_msgs/JointTrajectory
|
||||
|
||||
# What to publish? Can save some bandwidth as most robots only require positions or velocities
|
||||
publish_joint_positions: true
|
||||
publish_joint_velocities: true
|
||||
publish_joint_accelerations: false
|
||||
|
||||
## Plugins for smoothing outgoing commands
|
||||
smoothing_filter_plugin_name: "online_signal_smoothing::ButterworthFilterPlugin"
|
||||
|
||||
# If is_primary_planning_scene_monitor is set to true, the Servo server's PlanningScene advertises the /get_planning_scene service,
|
||||
# which other nodes can use as a source for information about the planning environment.
|
||||
# NOTE: If a different node in your system is responsible for the "primary" planning scene instance (e.g. the MoveGroup node),
|
||||
# then is_primary_planning_scene_monitor needs to be set to false.
|
||||
is_primary_planning_scene_monitor: true
|
||||
|
||||
## MoveIt properties
|
||||
move_group_name: dummy2_arm # Often 'manipulator' or 'arm'
|
||||
planning_frame: base_link # The MoveIt planning frame. Often 'base_link' or 'world'
|
||||
|
||||
## Other frames
|
||||
ee_frame_name: J6_1 # The name of the end effector link, used to return the EE pose
|
||||
robot_link_command_frame: base_link # commands must be given in the frame of a robot link. Usually either the base or end effector
|
||||
|
||||
## Stopping behaviour
|
||||
incoming_command_timeout: 0.1 # Stop servoing if X seconds elapse without a new command
|
||||
# If 0, republish commands forever even if the robot is stationary. Otherwise, specify num. to publish.
|
||||
# Important because ROS may drop some messages and we need the robot to halt reliably.
|
||||
num_outgoing_halt_msgs_to_publish: 4
|
||||
|
||||
## Configure handling of singularities and joint limits
|
||||
lower_singularity_threshold: 170.0 # Start decelerating when the condition number hits this (close to singularity)
|
||||
hard_stop_singularity_threshold: 3000.0 # Stop when the condition number hits this
|
||||
joint_limit_margin: 0.1 # added as a buffer to joint limits [radians]. If moving quickly, make this larger.
|
||||
leaving_singularity_threshold_multiplier: 2.0 # Multiply the hard stop limit by this when leaving singularity (see https://github.com/ros-planning/moveit2/pull/620)
|
||||
|
||||
## Topic names
|
||||
cartesian_command_in_topic: ~/delta_twist_cmds # Topic for incoming Cartesian twist commands
|
||||
joint_command_in_topic: ~/delta_joint_cmds # Topic for incoming joint angle commands
|
||||
joint_topic: /joint_states
|
||||
status_topic: ~/status # Publish status to this topic
|
||||
command_out_topic: /dummy2_arm_controller/joint_trajectory # Publish outgoing commands here
|
||||
|
||||
## Collision checking for the entire robot body
|
||||
check_collisions: true # Check collisions?
|
||||
collision_check_rate: 10.0 # [Hz] Collision-checking can easily bog down a CPU if done too often.
|
||||
self_collision_proximity_threshold: 0.001 # Start decelerating when a self-collision is this far [m]
|
||||
scene_collision_proximity_threshold: 0.002 # Start decelerating when a scene collision is this far [m]
|
||||
@@ -0,0 +1,9 @@
|
||||
# Default initial positions for dummy2's ros2_control fake system
|
||||
|
||||
initial_positions:
|
||||
Joint1: 0
|
||||
Joint2: 0
|
||||
Joint3: 0
|
||||
Joint4: 0
|
||||
Joint5: 0
|
||||
Joint6: 0
|
||||
@@ -0,0 +1,40 @@
|
||||
# joint_limits.yaml allows the dynamics properties specified in the URDF to be overwritten or augmented as needed
|
||||
|
||||
# For beginners, we downscale velocity and acceleration limits.
|
||||
# You can always specify higher scaling factors (<= 1.0) in your motion requests. # Increase the values below to 1.0 to always move at maximum speed.
|
||||
default_velocity_scaling_factor: 0.1
|
||||
default_acceleration_scaling_factor: 0.1
|
||||
|
||||
# Specific joint properties can be changed with the keys [max_position, min_position, max_velocity, max_acceleration]
|
||||
# Joint limits can be turned off with [has_velocity_limits, has_acceleration_limits]
|
||||
joint_limits:
|
||||
joint_1:
|
||||
has_velocity_limits: true
|
||||
max_velocity: 2.0
|
||||
has_acceleration_limits: false
|
||||
max_acceleration: 0
|
||||
joint_2:
|
||||
has_velocity_limits: true
|
||||
max_velocity: 2.0
|
||||
has_acceleration_limits: false
|
||||
max_acceleration: 0
|
||||
joint_3:
|
||||
has_velocity_limits: true
|
||||
max_velocity: 2.0
|
||||
has_acceleration_limits: false
|
||||
max_acceleration: 0
|
||||
joint_4:
|
||||
has_velocity_limits: true
|
||||
max_velocity: 2.0
|
||||
has_acceleration_limits: false
|
||||
max_acceleration: 0
|
||||
joint_5:
|
||||
has_velocity_limits: true
|
||||
max_velocity: 2.0
|
||||
has_acceleration_limits: false
|
||||
max_acceleration: 0
|
||||
joint_6:
|
||||
has_velocity_limits: true
|
||||
max_velocity: 2.0
|
||||
has_acceleration_limits: false
|
||||
max_acceleration: 0
|
||||
@@ -0,0 +1,4 @@
|
||||
dummy2_arm:
|
||||
kinematics_solver: kdl_kinematics_plugin/KDLKinematicsPlugin
|
||||
kinematics_solver_search_resolution: 0.0050000000000000001
|
||||
kinematics_solver_timeout: 0.5
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0"?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
|
||||
<xacro:macro name="dummy2_robot_ros2_control" params="device_name mesh_path">
|
||||
<xacro:property name="initial_positions" value="${load_yaml(mesh_path + '/devices/dummy2_robot/config/initial_positions.yaml')['initial_positions']}"/>
|
||||
|
||||
<ros2_control name="${device_name}dummy2" type="system">
|
||||
<hardware>
|
||||
<!-- By default, set up controllers for simulation. This won't work on real hardware -->
|
||||
<plugin>mock_components/GenericSystem</plugin>
|
||||
</hardware>
|
||||
|
||||
<!-- <plugin>mock_components/GenericSystem</plugin> -->
|
||||
<joint name="${device_name}Joint1">
|
||||
<command_interface name="position"/>
|
||||
<state_interface name="position">
|
||||
<param name="initial_value">${initial_positions['Joint1']}</param>
|
||||
</state_interface>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="${device_name}Joint2">
|
||||
<command_interface name="position"/>
|
||||
<state_interface name="position">
|
||||
<param name="initial_value">${initial_positions['Joint2']}</param>
|
||||
</state_interface>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="${device_name}Joint3">
|
||||
<command_interface name="position"/>
|
||||
<state_interface name="position">
|
||||
<param name="initial_value">${initial_positions['Joint3']}</param>
|
||||
</state_interface>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="${device_name}Joint4">
|
||||
<command_interface name="position"/>
|
||||
<state_interface name="position">
|
||||
<param name="initial_value">${initial_positions['Joint4']}</param>
|
||||
</state_interface>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="${device_name}Joint5">
|
||||
<command_interface name="position"/>
|
||||
<state_interface name="position">
|
||||
<param name="initial_value">${initial_positions['Joint5']}</param>
|
||||
</state_interface>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="${device_name}Joint6">
|
||||
<command_interface name="position"/>
|
||||
<state_interface name="position">
|
||||
<param name="initial_value">${initial_positions['Joint6']}</param>
|
||||
</state_interface>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
|
||||
|
||||
</ros2_control>
|
||||
</xacro:macro>
|
||||
|
||||
</robot>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
|
||||
<xacro:macro name="dummy2_robot_srdf" params="device_name">
|
||||
|
||||
<!--GROUPS: Representation of a set of joints and links. This can be useful for specifying DOF to plan for, defining arms, end effectors, etc-->
|
||||
<!--LINKS: When a link is specified, the parent joint of that link (if it exists) is automatically included-->
|
||||
<!--JOINTS: When a joint is specified, the child link of that joint (which will always exist) is automatically included-->
|
||||
<!--CHAINS: When a chain is specified, all the links along the chain (including endpoints) are included in the group. Additionally, all the joints that are parents to included links are also included. This means that joints along the chain and the parent joint of the base link are included in the group-->
|
||||
<!--SUBGROUPS: Groups can also be formed by referencing to already defined group names
|
||||
This is a format for representing semantic information about the robot structure.
|
||||
A URDF file must exist for this robot as well, where the joints and the links that are referenced are defined
|
||||
-->
|
||||
<group name="${device_name}dummy2_arm">
|
||||
<joint name="${device_name}virtual_joint"/>
|
||||
<joint name="${device_name}Joint1"/>
|
||||
<joint name="${device_name}Joint2"/>
|
||||
<joint name="${device_name}Joint3"/>
|
||||
<joint name="${device_name}Joint4"/>
|
||||
<joint name="${device_name}Joint5"/>
|
||||
<joint name="${device_name}Joint6"/>
|
||||
</group>
|
||||
<!--GROUP STATES: Purpose: Define a named state for a particular group, in terms of joint values. This is useful to define states like 'folded arms'-->
|
||||
<group_state name="home" group="${device_name}dummy2_arm">
|
||||
<joint name="${device_name}Joint1" value="0"/>
|
||||
<joint name="${device_name}Joint2" value="0"/>
|
||||
<joint name="${device_name}Joint3" value="0"/>
|
||||
<joint name="${device_name}Joint4" value="0"/>
|
||||
<joint name="${device_name}Joint5" value="0"/>
|
||||
<joint name="${device_name}Joint6" value="0"/>
|
||||
</group_state>
|
||||
<!--VIRTUAL JOINT: Purpose: this element defines a virtual joint between a robot link and an external frame of reference (considered fixed with respect to the robot)-->
|
||||
<virtual_joint name="${device_name}virtual_joint" type="fixed" parent_frame="world" child_link="${device_name}base_link"/>
|
||||
<!--DISABLE COLLISIONS: By default it is assumed that any link of the robot could potentially come into collision with any other link in the robot. This tag disables collision checking between a specified pair of links. -->
|
||||
<disable_collisions link1="${device_name}J1_1" link2="${device_name}J2_1" reason="Adjacent"/>
|
||||
<disable_collisions link1="${device_name}J1_1" link2="${device_name}J3_1" reason="Never"/>
|
||||
<disable_collisions link1="${device_name}J1_1" link2="${device_name}J4_1" reason="Never"/>
|
||||
<disable_collisions link1="${device_name}J1_1" link2="${device_name}base_link" reason="Adjacent"/>
|
||||
<disable_collisions link1="${device_name}J2_1" link2="${device_name}J3_1" reason="Adjacent"/>
|
||||
<disable_collisions link1="${device_name}J3_1" link2="${device_name}J4_1" reason="Adjacent"/>
|
||||
<disable_collisions link1="${device_name}J3_1" link2="${device_name}J5_1" reason="Never"/>
|
||||
<disable_collisions link1="${device_name}J3_1" link2="${device_name}J6_1" reason="Never"/>
|
||||
<disable_collisions link1="${device_name}J3_1" link2="${device_name}base_link" reason="Never"/>
|
||||
<disable_collisions link1="${device_name}J4_1" link2="${device_name}J5_1" reason="Adjacent"/>
|
||||
<disable_collisions link1="${device_name}J4_1" link2="${device_name}J6_1" reason="Never"/>
|
||||
<disable_collisions link1="${device_name}J5_1" link2="${device_name}J6_1" reason="Adjacent"/>
|
||||
|
||||
</xacro:macro>
|
||||
|
||||
</robot>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot name="dummy2" xmlns:xacro="http://www.ros.org/wiki/xacro" >
|
||||
|
||||
<material name="silver">
|
||||
<color rgba="0.700 0.700 0.700 1.000"/>
|
||||
</material>
|
||||
|
||||
</robot>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"arm": {
|
||||
"joint_names": [
|
||||
"joint_1",
|
||||
"joint_2",
|
||||
"joint_3",
|
||||
"joint_4",
|
||||
"joint_5",
|
||||
"joint_6"
|
||||
],
|
||||
"base_link_name": "base_link",
|
||||
"end_effector_name": "J6_1"
|
||||
}
|
||||
}
|
||||
51
unilabos/device_mesh/devices/dummy2_robot/config/moveit.rviz
Normal file
51
unilabos/device_mesh/devices/dummy2_robot/config/moveit.rviz
Normal file
@@ -0,0 +1,51 @@
|
||||
Panels:
|
||||
- Class: rviz_common/Displays
|
||||
Name: Displays
|
||||
Property Tree Widget:
|
||||
Expanded:
|
||||
- /MotionPlanning1
|
||||
- Class: rviz_common/Help
|
||||
Name: Help
|
||||
- Class: rviz_common/Views
|
||||
Name: Views
|
||||
Visualization Manager:
|
||||
Displays:
|
||||
- Class: rviz_default_plugins/Grid
|
||||
Name: Grid
|
||||
Value: true
|
||||
- Class: moveit_rviz_plugin/MotionPlanning
|
||||
Name: MotionPlanning
|
||||
Planned Path:
|
||||
Loop Animation: true
|
||||
State Display Time: 0.05 s
|
||||
Trajectory Topic: display_planned_path
|
||||
Planning Scene Topic: monitored_planning_scene
|
||||
Robot Description: robot_description
|
||||
Scene Geometry:
|
||||
Scene Alpha: 1
|
||||
Scene Robot:
|
||||
Robot Alpha: 0.5
|
||||
Value: true
|
||||
Global Options:
|
||||
Fixed Frame: base_link
|
||||
Tools:
|
||||
- Class: rviz_default_plugins/Interact
|
||||
- Class: rviz_default_plugins/MoveCamera
|
||||
- Class: rviz_default_plugins/Select
|
||||
Value: true
|
||||
Views:
|
||||
Current:
|
||||
Class: rviz_default_plugins/Orbit
|
||||
Distance: 2.0
|
||||
Focal Point:
|
||||
X: -0.1
|
||||
Y: 0.25
|
||||
Z: 0.30
|
||||
Name: Current View
|
||||
Pitch: 0.5
|
||||
Target Frame: base_link
|
||||
Yaw: -0.623
|
||||
Window Geometry:
|
||||
Height: 975
|
||||
QMainWindow State: 000000ff00000000fd0000000100000000000002b400000375fc0200000005fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000004100fffffffb000000100044006900730070006c006100790073010000003d00000123000000c900fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e00670100000166000001910000018800fffffffb0000000800480065006c0070000000029a0000006e0000006e00fffffffb0000000a0056006900650077007301000002fd000000b5000000a400ffffff000001f60000037500000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730100000000ffffffff0000000000000000
|
||||
Width: 1200
|
||||
@@ -0,0 +1,21 @@
|
||||
# MoveIt uses this configuration for controller management
|
||||
|
||||
moveit_controller_manager: moveit_simple_controller_manager/MoveItSimpleControllerManager
|
||||
|
||||
moveit_simple_controller_manager:
|
||||
controller_names:
|
||||
- dummy2_arm_controller
|
||||
|
||||
dummy2_arm_controller:
|
||||
type: FollowJointTrajectory
|
||||
action_ns: follow_joint_trajectory
|
||||
default: true
|
||||
joints:
|
||||
- Joint1
|
||||
- Joint2
|
||||
- Joint3
|
||||
- Joint4
|
||||
- Joint5
|
||||
- Joint6
|
||||
action_ns: follow_joint_trajectory
|
||||
default: true
|
||||
@@ -0,0 +1,39 @@
|
||||
dummy2_robot:
|
||||
# Physical properties for each link
|
||||
link_masses:
|
||||
base_link: 5.0
|
||||
link_1: 3.0
|
||||
link_2: 2.5
|
||||
link_3: 2.0
|
||||
link_4: 1.5
|
||||
link_5: 1.0
|
||||
link_6: 0.5
|
||||
|
||||
# Center of mass for each link (relative to joint frame)
|
||||
link_com:
|
||||
base_link: [0.0, 0.0, 0.05]
|
||||
link_1: [0.0, 0.0, 0.05]
|
||||
link_2: [0.1, 0.0, 0.0]
|
||||
link_3: [0.08, 0.0, 0.0]
|
||||
link_4: [0.0, 0.0, 0.05]
|
||||
link_5: [0.0, 0.0, 0.03]
|
||||
link_6: [0.0, 0.0, 0.02]
|
||||
|
||||
# Moment of inertia matrices
|
||||
link_inertias:
|
||||
base_link: [0.02, 0.0, 0.0, 0.02, 0.0, 0.02]
|
||||
link_1: [0.01, 0.0, 0.0, 0.01, 0.0, 0.01]
|
||||
link_2: [0.008, 0.0, 0.0, 0.008, 0.0, 0.008]
|
||||
link_3: [0.006, 0.0, 0.0, 0.006, 0.0, 0.006]
|
||||
link_4: [0.004, 0.0, 0.0, 0.004, 0.0, 0.004]
|
||||
link_5: [0.002, 0.0, 0.0, 0.002, 0.0, 0.002]
|
||||
link_6: [0.001, 0.0, 0.0, 0.001, 0.0, 0.001]
|
||||
|
||||
# Motor specifications
|
||||
motor_specs:
|
||||
joint_1: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 }
|
||||
joint_2: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 }
|
||||
joint_3: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 }
|
||||
joint_4: { max_torque: 50.0, max_speed: 2.0, gear_ratio: 50 }
|
||||
joint_5: { max_torque: 50.0, max_speed: 2.0, gear_ratio: 50 }
|
||||
joint_6: { max_torque: 25.0, max_speed: 2.0, gear_ratio: 25 }
|
||||
@@ -0,0 +1,6 @@
|
||||
# Limits for the Pilz planner
|
||||
cartesian_limits:
|
||||
max_trans_vel: 1.0
|
||||
max_trans_acc: 2.25
|
||||
max_trans_dec: -5.0
|
||||
max_rot_vel: 1.57
|
||||
@@ -0,0 +1,26 @@
|
||||
# This config file is used by ros2_control
|
||||
controller_manager:
|
||||
ros__parameters:
|
||||
update_rate: 100 # Hz
|
||||
|
||||
dummy2_arm_controller:
|
||||
type: joint_trajectory_controller/JointTrajectoryController
|
||||
|
||||
|
||||
joint_state_broadcaster:
|
||||
type: joint_state_broadcaster/JointStateBroadcaster
|
||||
|
||||
dummy2_arm_controller:
|
||||
ros__parameters:
|
||||
joints:
|
||||
- Joint1
|
||||
- Joint2
|
||||
- Joint3
|
||||
- Joint4
|
||||
- Joint5
|
||||
- Joint6
|
||||
command_interfaces:
|
||||
- position
|
||||
state_interfaces:
|
||||
- position
|
||||
- velocity
|
||||
@@ -0,0 +1,35 @@
|
||||
dummy2_robot:
|
||||
# Visual appearance settings
|
||||
materials:
|
||||
base_material:
|
||||
color: [0.8, 0.8, 0.8, 1.0] # Light gray
|
||||
metallic: 0.1
|
||||
roughness: 0.3
|
||||
|
||||
link_material:
|
||||
color: [0.2, 0.2, 0.8, 1.0] # Blue
|
||||
metallic: 0.3
|
||||
roughness: 0.2
|
||||
|
||||
joint_material:
|
||||
color: [0.6, 0.6, 0.6, 1.0] # Dark gray
|
||||
metallic: 0.5
|
||||
roughness: 0.1
|
||||
|
||||
camera_material:
|
||||
color: [0.1, 0.1, 0.1, 1.0] # Black
|
||||
metallic: 0.0
|
||||
roughness: 0.8
|
||||
|
||||
# Mesh scaling factors
|
||||
mesh_scale: [0.001, 0.001, 0.001] # Convert mm to m
|
||||
|
||||
# Collision geometry simplification
|
||||
collision_geometries:
|
||||
base_link: "cylinder" # radius: 0.08, height: 0.1
|
||||
link_1: "cylinder" # radius: 0.05, height: 0.15
|
||||
link_2: "box" # size: [0.2, 0.08, 0.08]
|
||||
link_3: "box" # size: [0.15, 0.06, 0.06]
|
||||
link_4: "cylinder" # radius: 0.03, height: 0.1
|
||||
link_5: "cylinder" # radius: 0.025, height: 0.06
|
||||
link_6: "cylinder" # radius: 0.02, height: 0.04
|
||||
237
unilabos/device_mesh/devices/dummy2_robot/dummy2.xacro
Normal file
237
unilabos/device_mesh/devices/dummy2_robot/dummy2.xacro
Normal file
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot name="dummy2" xmlns:xacro="http://www.ros.org/wiki/xacro">
|
||||
|
||||
<xacro:include filename="$(find dummy2_description)/urdf/materials.xacro" />
|
||||
<xacro:include filename="$(find dummy2_description)/urdf/dummy2.trans" />
|
||||
<xacro:include filename="$(find dummy2_description)/urdf/dummy2.gazebo" />
|
||||
<link name="world" />
|
||||
<joint name="world_joint" type="fixed">
|
||||
<parent link="world" />
|
||||
<child link = "base_link" />
|
||||
<origin xyz="0.0 0.0 0.0" rpy="0.0 0.0 0.0" />
|
||||
</joint>
|
||||
|
||||
<link name="base_link">
|
||||
<inertial>
|
||||
<origin xyz="0.00010022425916431473 -6.186605493937309e-05 0.05493640543484716" rpy="0 0 0"/>
|
||||
<mass value="1.2152141810431654"/>
|
||||
<inertia ixx="0.002105" iyy="0.002245" izz="0.002436" ixy="-0.0" iyz="-1.1e-05" ixz="0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/base_link.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/base_link.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J1_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.00617659688932347 0.007029599744830012 0.012866826083045027" rpy="0 0 0"/>
|
||||
<mass value="0.1332774369186824"/>
|
||||
<inertia ixx="6e-05" iyy="5e-05" izz="8.8e-05" ixy="2.1e-05" iyz="-1.4e-05" ixz="8e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.0001 0.000289 -0.097579" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J1_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.0001 0.000289 -0.097579" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J1_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J2_1">
|
||||
<inertial>
|
||||
<origin xyz="0.019335709221765855 0.0019392793940843159 0.07795928103332703" rpy="0 0 0"/>
|
||||
<mass value="1.9268013917303417"/>
|
||||
<inertia ixx="0.006165" iyy="0.006538" izz="0.00118" ixy="-3e-06" iyz="4.7e-05" ixz="0.0007"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0.011539 -0.034188 -0.12478" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J2_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0.011539 -0.034188 -0.12478" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J2_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J3_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.010672101243726572 -0.02723871972304964 0.04876701375652198" rpy="0 0 0"/>
|
||||
<mass value="0.30531962155452225"/>
|
||||
<inertia ixx="0.00029" iyy="0.000238" izz="0.000191" ixy="-1.3e-05" iyz="4.1e-05" ixz="3e-05"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.023811 -0.034188 -0.28278" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J3_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.023811 -0.034188 -0.28278" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J3_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J4_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.005237398377441591 0.06002028183461833 0.0005891767740203724" rpy="0 0 0"/>
|
||||
<mass value="0.14051172121899885"/>
|
||||
<inertia ixx="0.000245" iyy="7.9e-05" izz="0.00027" ixy="1.6e-05" iyz="-2e-06" ixz="1e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.010649 -0.038288 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J4_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.010649 -0.038288 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J4_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J5_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.014389813882964664 0.07305218143664277 -0.0009243405950149497" rpy="0 0 0"/>
|
||||
<mass value="0.7783315754227634"/>
|
||||
<inertia ixx="0.000879" iyy="0.000339" izz="0.000964" ixy="0.000146" iyz="1e-06" ixz="-5e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.031949 -0.148289 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J5_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.031949 -0.148289 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J5_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J6_1">
|
||||
<inertial>
|
||||
<origin xyz="3.967160300787087e-07 0.0004995066702210837 1.4402781733924286e-07" rpy="0 0 0"/>
|
||||
<mass value="0.0020561527568204153"/>
|
||||
<inertia ixx="0.0" iyy="0.0" izz="0.0" ixy="0.0" iyz="0.0" ixz="0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.012127 -0.267789 -0.344021" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J6_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.012127 -0.267789 -0.344021" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J6_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="camera">
|
||||
<inertial>
|
||||
<origin xyz="-0.0006059984983273845 0.0005864706438700462 0.04601775357664567" rpy="0 0 0"/>
|
||||
<mass value="0.21961029019655884"/>
|
||||
<inertia ixx="2.9e-05" iyy="0.000206" izz="0.000198" ixy="-0.0" iyz="2e-06" ixz="-0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.012661 -0.239774 -0.37985" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/camera_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.012661 -0.239774 -0.37985" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/camera_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<joint name="Joint1" type="revolute">
|
||||
<origin xyz="0.0001 -0.000289 0.097579" rpy="0 0 0"/>
|
||||
<parent link="base_link"/>
|
||||
<child link="J1_1"/>
|
||||
<axis xyz="-0.0 -0.0 1.0"/>
|
||||
<limit upper="3.054326" lower="-3.054326" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint2" type="revolute">
|
||||
<origin xyz="-0.011639 0.034477 0.027201" rpy="0 0 0"/>
|
||||
<parent link="J1_1"/>
|
||||
<child link="J2_1"/>
|
||||
<axis xyz="1.0 0.0 -0.0"/>
|
||||
<limit upper="1.308997" lower="-2.007129" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint3" type="revolute">
|
||||
<origin xyz="0.03535 0.0 0.158" rpy="0 0 0"/>
|
||||
<parent link="J2_1"/>
|
||||
<child link="J3_1"/>
|
||||
<axis xyz="-1.0 0.0 -0.0"/>
|
||||
<limit upper="1.570796" lower="-1.047198" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint4" type="revolute">
|
||||
<origin xyz="-0.013162 0.0041 0.062466" rpy="0 0 0"/>
|
||||
<parent link="J3_1"/>
|
||||
<child link="J4_1"/>
|
||||
<axis xyz="0.0 1.0 -0.0"/>
|
||||
<limit upper="3.141593" lower="-3.141593" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint5" type="revolute">
|
||||
<origin xyz="0.0213 0.110001 0.0" rpy="0 0 0"/>
|
||||
<parent link="J4_1"/>
|
||||
<child link="J5_1"/>
|
||||
<axis xyz="-1.0 -0.0 -0.0"/>
|
||||
<limit upper="2.094395" lower="-1.919862" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint6" type="continuous">
|
||||
<origin xyz="-0.019822 0.1195 -0.001225" rpy="0 0 0"/>
|
||||
<parent link="J5_1"/>
|
||||
<child link="J6_1"/>
|
||||
<axis xyz="0.0 -1.0 0.0"/>
|
||||
</joint>
|
||||
|
||||
<joint name="camera" type="fixed">
|
||||
<origin xyz="-0.019988 0.091197 0.024883" rpy="0 0 0"/>
|
||||
<parent link="J5_1"/>
|
||||
<child link="camera"/>
|
||||
<axis xyz="1.0 -0.0 0.0"/>
|
||||
<limit upper="0.0" lower="0.0" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
</robot>
|
||||
37
unilabos/device_mesh/devices/dummy2_robot/joint_limit.yaml
Normal file
37
unilabos/device_mesh/devices/dummy2_robot/joint_limit.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
joint_limits:
|
||||
|
||||
joint_1:
|
||||
effort: 150
|
||||
velocity: 2.0
|
||||
lower: !degrees -180
|
||||
upper: !degrees 180
|
||||
|
||||
joint_2:
|
||||
effort: 150
|
||||
velocity: 2.0
|
||||
lower: !degrees -90
|
||||
upper: !degrees 90
|
||||
|
||||
joint_3:
|
||||
effort: 150
|
||||
velocity: 2.0
|
||||
lower: !degrees -90
|
||||
upper: !degrees 90
|
||||
|
||||
joint_4:
|
||||
effort: 50
|
||||
velocity: 2.0
|
||||
lower: !degrees -180
|
||||
upper: !degrees 180
|
||||
|
||||
joint_5:
|
||||
effort: 50
|
||||
velocity: 2.0
|
||||
lower: !degrees -90
|
||||
upper: !degrees 90
|
||||
|
||||
joint_6:
|
||||
effort: 25
|
||||
velocity: 2.0
|
||||
lower: !degrees -180
|
||||
upper: !degrees 180
|
||||
249
unilabos/device_mesh/devices/dummy2_robot/macro_device.xacro
Normal file
249
unilabos/device_mesh/devices/dummy2_robot/macro_device.xacro
Normal file
@@ -0,0 +1,249 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot name="dummy2" xmlns:xacro="http://www.ros.org/wiki/xacro">
|
||||
|
||||
|
||||
<xacro:macro name="dummy2_robot" params="mesh_path:='' parent_link:='' station_name:='' device_name:='' x:=0 y:=0 z:=0 rx:=0 ry:=0 r:=0">
|
||||
|
||||
<xacro:include filename="${mesh_path}/devices/dummy2_robot/config/materials.xacro" />
|
||||
<xacro:include filename="${mesh_path}/devices/dummy2_robot/config/dummy2.trans" />
|
||||
|
||||
<joint name="${station_name}${device_name}base_link_joint" type="fixed">
|
||||
<origin xyz="${x} ${y} ${z}" rpy="${rx} ${ry} ${r}" />
|
||||
<parent link="${parent_link}"/>
|
||||
<child link="${station_name}${device_name}device_link"/>
|
||||
<axis xyz="0 0 0"/>
|
||||
</joint>
|
||||
|
||||
<link name="${station_name}${device_name}device_link"/>
|
||||
<joint name="${station_name}${device_name}device_link_joint" type="fixed">
|
||||
<origin xyz="0 0 0" rpy="0 0 0" />
|
||||
<parent link="${station_name}${device_name}device_link"/>
|
||||
<child link="${station_name}${device_name}base_link"/>
|
||||
<axis xyz="0 0 0"/>
|
||||
</joint>
|
||||
|
||||
<link name="${station_name}${device_name}base_link">
|
||||
<inertial>
|
||||
<origin xyz="0.00010022425916431473 -6.186605493937309e-05 0.05493640543484716" rpy="0 0 0"/>
|
||||
<mass value="1.2152141810431654"/>
|
||||
<inertia ixx="0.002105" iyy="0.002245" izz="0.002436" ixy="-0.0" iyz="-1.1e-05" ixz="0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/base_link.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/base_link.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="${station_name}${device_name}J1_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.00617659688932347 0.007029599744830012 0.012866826083045027" rpy="0 0 0"/>
|
||||
<mass value="0.1332774369186824"/>
|
||||
<inertia ixx="6e-05" iyy="5e-05" izz="8.8e-05" ixy="2.1e-05" iyz="-1.4e-05" ixz="8e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.0001 0.000289 -0.097579" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J1_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.0001 0.000289 -0.097579" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J1_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="${station_name}${device_name}J2_1">
|
||||
<inertial>
|
||||
<origin xyz="0.019335709221765855 0.0019392793940843159 0.07795928103332703" rpy="0 0 0"/>
|
||||
<mass value="1.9268013917303417"/>
|
||||
<inertia ixx="0.006165" iyy="0.006538" izz="0.00118" ixy="-3e-06" iyz="4.7e-05" ixz="0.0007"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0.011539 -0.034188 -0.12478" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J2_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0.011539 -0.034188 -0.12478" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J2_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="${station_name}${device_name}J3_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.010672101243726572 -0.02723871972304964 0.04876701375652198" rpy="0 0 0"/>
|
||||
<mass value="0.30531962155452225"/>
|
||||
<inertia ixx="0.00029" iyy="0.000238" izz="0.000191" ixy="-1.3e-05" iyz="4.1e-05" ixz="3e-05"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.023811 -0.034188 -0.28278" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J3_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.023811 -0.034188 -0.28278" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J3_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="${station_name}${device_name}J4_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.005237398377441591 0.06002028183461833 0.0005891767740203724" rpy="0 0 0"/>
|
||||
<mass value="0.14051172121899885"/>
|
||||
<inertia ixx="0.000245" iyy="7.9e-05" izz="0.00027" ixy="1.6e-05" iyz="-2e-06" ixz="1e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.010649 -0.038288 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J4_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.010649 -0.038288 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J4_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="${station_name}${device_name}J5_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.014389813882964664 0.07305218143664277 -0.0009243405950149497" rpy="0 0 0"/>
|
||||
<mass value="0.7783315754227634"/>
|
||||
<inertia ixx="0.000879" iyy="0.000339" izz="0.000964" ixy="0.000146" iyz="1e-06" ixz="-5e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.031949 -0.148289 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J5_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.031949 -0.148289 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J5_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="${station_name}${device_name}J6_1">
|
||||
<inertial>
|
||||
<origin xyz="3.967160300787087e-07 0.0004995066702210837 1.4402781733924286e-07" rpy="0 0 0"/>
|
||||
<mass value="0.0020561527568204153"/>
|
||||
<inertia ixx="0.0" iyy="0.0" izz="0.0" ixy="0.0" iyz="0.0" ixz="0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.012127 -0.267789 -0.344021" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J6_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.012127 -0.267789 -0.344021" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/J6_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="${station_name}${device_name}camera">
|
||||
<inertial>
|
||||
<origin xyz="-0.0006059984983273845 0.0005864706438700462 0.04601775357664567" rpy="0 0 0"/>
|
||||
<mass value="0.21961029019655884"/>
|
||||
<inertia ixx="2.9e-05" iyy="0.000206" izz="0.000198" ixy="-0.0" iyz="2e-06" ixz="-0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.012661 -0.239774 -0.37985" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/camera_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.012661 -0.239774 -0.37985" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://${mesh_path}/devices/dummy2_robot/meshes/camera_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<joint name="${station_name}${device_name}Joint1" type="revolute">
|
||||
<origin xyz="0.0001 -0.000289 0.097579" rpy="0 0 0"/>
|
||||
<parent link="${station_name}${device_name}base_link"/>
|
||||
<child link="${station_name}${device_name}J1_1"/>
|
||||
<axis xyz="-0.0 -0.0 1.0"/>
|
||||
<limit upper="3.054326" lower="-3.054326" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="${station_name}${device_name}Joint2" type="revolute">
|
||||
<origin xyz="-0.011639 0.034477 0.027201" rpy="0 0 0"/>
|
||||
<parent link="${station_name}${device_name}J1_1"/>
|
||||
<child link="${station_name}${device_name}J2_1"/>
|
||||
<axis xyz="1.0 0.0 -0.0"/>
|
||||
<limit upper="1.308997" lower="-2.007129" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="${station_name}${device_name}Joint3" type="revolute">
|
||||
<origin xyz="0.03535 0.0 0.158" rpy="0 0 0"/>
|
||||
<parent link="${station_name}${device_name}J2_1"/>
|
||||
<child link="${station_name}${device_name}J3_1"/>
|
||||
<axis xyz="-1.0 0.0 -0.0"/>
|
||||
<limit upper="1.570796" lower="-1.047198" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="${station_name}${device_name}Joint4" type="revolute">
|
||||
<origin xyz="-0.013162 0.0041 0.062466" rpy="0 0 0"/>
|
||||
<parent link="${station_name}${device_name}J3_1"/>
|
||||
<child link="${station_name}${device_name}J4_1"/>
|
||||
<axis xyz="0.0 1.0 -0.0"/>
|
||||
<limit upper="3.141593" lower="-3.141593" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="${station_name}${device_name}Joint5" type="revolute">
|
||||
<origin xyz="0.0213 0.110001 0.0" rpy="0 0 0"/>
|
||||
<parent link="${station_name}${device_name}J4_1"/>
|
||||
<child link="${station_name}${device_name}J5_1"/>
|
||||
<axis xyz="-1.0 -0.0 -0.0"/>
|
||||
<limit upper="2.094395" lower="-1.919862" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="${station_name}${device_name}Joint6" type="continuous">
|
||||
<origin xyz="-0.019822 0.1195 -0.001225" rpy="0 0 0"/>
|
||||
<parent link="${station_name}${device_name}J5_1"/>
|
||||
<child link="${station_name}${device_name}J6_1"/>
|
||||
<axis xyz="0.0 -1.0 0.0"/>
|
||||
</joint>
|
||||
|
||||
<joint name="${station_name}${device_name}camera" type="fixed">
|
||||
<origin xyz="-0.019988 0.091197 0.024883" rpy="0 0 0"/>
|
||||
<parent link="${station_name}${device_name}J5_1"/>
|
||||
<child link="${station_name}${device_name}camera"/>
|
||||
<axis xyz="1.0 -0.0 0.0"/>
|
||||
<limit upper="0.0" lower="0.0" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
</xacro:macro>
|
||||
|
||||
</robot>
|
||||
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J1_1.stl
Normal file
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J1_1.stl
Normal file
Binary file not shown.
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J2_1.stl
Normal file
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J2_1.stl
Normal file
Binary file not shown.
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J3_1.stl
Normal file
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J3_1.stl
Normal file
Binary file not shown.
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J4_1.stl
Normal file
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J4_1.stl
Normal file
Binary file not shown.
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J5_1.stl
Normal file
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J5_1.stl
Normal file
Binary file not shown.
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J6_1.stl
Normal file
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/J6_1.stl
Normal file
Binary file not shown.
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/base_link.stl
Normal file
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/base_link.stl
Normal file
Binary file not shown.
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/camera_1.stl
Normal file
BIN
unilabos/device_mesh/devices/dummy2_robot/meshes/camera_1.stl
Normal file
Binary file not shown.
237
unilabos/device_mesh/devices/dummy2_robot/meshes/dummy2.xacro
Normal file
237
unilabos/device_mesh/devices/dummy2_robot/meshes/dummy2.xacro
Normal file
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot name="dummy2" xmlns:xacro="http://www.ros.org/wiki/xacro">
|
||||
|
||||
<xacro:include filename="$(find dummy2_description)/urdf/materials.xacro" />
|
||||
<xacro:include filename="$(find dummy2_description)/urdf/dummy2.trans" />
|
||||
<xacro:include filename="$(find dummy2_description)/urdf/dummy2.gazebo" />
|
||||
<link name="world" />
|
||||
<joint name="world_joint" type="fixed">
|
||||
<parent link="world" />
|
||||
<child link = "base_link" />
|
||||
<origin xyz="0.0 0.0 0.0" rpy="0.0 0.0 0.0" />
|
||||
</joint>
|
||||
|
||||
<link name="base_link">
|
||||
<inertial>
|
||||
<origin xyz="0.00010022425916431473 -6.186605493937309e-05 0.05493640543484716" rpy="0 0 0"/>
|
||||
<mass value="1.2152141810431654"/>
|
||||
<inertia ixx="0.002105" iyy="0.002245" izz="0.002436" ixy="-0.0" iyz="-1.1e-05" ixz="0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/base_link.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/base_link.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J1_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.00617659688932347 0.007029599744830012 0.012866826083045027" rpy="0 0 0"/>
|
||||
<mass value="0.1332774369186824"/>
|
||||
<inertia ixx="6e-05" iyy="5e-05" izz="8.8e-05" ixy="2.1e-05" iyz="-1.4e-05" ixz="8e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.0001 0.000289 -0.097579" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J1_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.0001 0.000289 -0.097579" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J1_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J2_1">
|
||||
<inertial>
|
||||
<origin xyz="0.019335709221765855 0.0019392793940843159 0.07795928103332703" rpy="0 0 0"/>
|
||||
<mass value="1.9268013917303417"/>
|
||||
<inertia ixx="0.006165" iyy="0.006538" izz="0.00118" ixy="-3e-06" iyz="4.7e-05" ixz="0.0007"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0.011539 -0.034188 -0.12478" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J2_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0.011539 -0.034188 -0.12478" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J2_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J3_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.010672101243726572 -0.02723871972304964 0.04876701375652198" rpy="0 0 0"/>
|
||||
<mass value="0.30531962155452225"/>
|
||||
<inertia ixx="0.00029" iyy="0.000238" izz="0.000191" ixy="-1.3e-05" iyz="4.1e-05" ixz="3e-05"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.023811 -0.034188 -0.28278" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J3_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.023811 -0.034188 -0.28278" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J3_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J4_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.005237398377441591 0.06002028183461833 0.0005891767740203724" rpy="0 0 0"/>
|
||||
<mass value="0.14051172121899885"/>
|
||||
<inertia ixx="0.000245" iyy="7.9e-05" izz="0.00027" ixy="1.6e-05" iyz="-2e-06" ixz="1e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.010649 -0.038288 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J4_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.010649 -0.038288 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J4_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J5_1">
|
||||
<inertial>
|
||||
<origin xyz="-0.014389813882964664 0.07305218143664277 -0.0009243405950149497" rpy="0 0 0"/>
|
||||
<mass value="0.7783315754227634"/>
|
||||
<inertia ixx="0.000879" iyy="0.000339" izz="0.000964" ixy="0.000146" iyz="1e-06" ixz="-5e-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.031949 -0.148289 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J5_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.031949 -0.148289 -0.345246" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J5_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="J6_1">
|
||||
<inertial>
|
||||
<origin xyz="3.967160300787087e-07 0.0004995066702210837 1.4402781733924286e-07" rpy="0 0 0"/>
|
||||
<mass value="0.0020561527568204153"/>
|
||||
<inertia ixx="0.0" iyy="0.0" izz="0.0" ixy="0.0" iyz="0.0" ixz="0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.012127 -0.267789 -0.344021" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J6_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.012127 -0.267789 -0.344021" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/J6_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<link name="camera">
|
||||
<inertial>
|
||||
<origin xyz="-0.0006059984983273845 0.0005864706438700462 0.04601775357664567" rpy="0 0 0"/>
|
||||
<mass value="0.21961029019655884"/>
|
||||
<inertia ixx="2.9e-05" iyy="0.000206" izz="0.000198" ixy="-0.0" iyz="2e-06" ixz="-0.0"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="-0.012661 -0.239774 -0.37985" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/camera_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
<material name="silver"/>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="-0.012661 -0.239774 -0.37985" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="file://$(find dummy2_description)/meshes/camera_1.stl" scale="0.001 0.001 0.001"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
|
||||
<joint name="Joint1" type="revolute">
|
||||
<origin xyz="0.0001 -0.000289 0.097579" rpy="0 0 0"/>
|
||||
<parent link="base_link"/>
|
||||
<child link="J1_1"/>
|
||||
<axis xyz="-0.0 -0.0 1.0"/>
|
||||
<limit upper="3.054326" lower="-3.054326" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint2" type="revolute">
|
||||
<origin xyz="-0.011639 0.034477 0.027201" rpy="0 0 0"/>
|
||||
<parent link="J1_1"/>
|
||||
<child link="J2_1"/>
|
||||
<axis xyz="1.0 0.0 -0.0"/>
|
||||
<limit upper="1.308997" lower="-2.007129" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint3" type="revolute">
|
||||
<origin xyz="0.03535 0.0 0.158" rpy="0 0 0"/>
|
||||
<parent link="J2_1"/>
|
||||
<child link="J3_1"/>
|
||||
<axis xyz="-1.0 0.0 -0.0"/>
|
||||
<limit upper="1.570796" lower="-1.047198" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint4" type="revolute">
|
||||
<origin xyz="-0.013162 0.0041 0.062466" rpy="0 0 0"/>
|
||||
<parent link="J3_1"/>
|
||||
<child link="J4_1"/>
|
||||
<axis xyz="0.0 1.0 -0.0"/>
|
||||
<limit upper="3.141593" lower="-3.141593" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint5" type="revolute">
|
||||
<origin xyz="0.0213 0.110001 0.0" rpy="0 0 0"/>
|
||||
<parent link="J4_1"/>
|
||||
<child link="J5_1"/>
|
||||
<axis xyz="-1.0 -0.0 -0.0"/>
|
||||
<limit upper="2.094395" lower="-1.919862" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
<joint name="Joint6" type="continuous">
|
||||
<origin xyz="-0.019822 0.1195 -0.001225" rpy="0 0 0"/>
|
||||
<parent link="J5_1"/>
|
||||
<child link="J6_1"/>
|
||||
<axis xyz="0.0 -1.0 0.0"/>
|
||||
</joint>
|
||||
|
||||
<joint name="camera" type="fixed">
|
||||
<origin xyz="-0.019988 0.091197 0.024883" rpy="0 0 0"/>
|
||||
<parent link="J5_1"/>
|
||||
<child link="camera"/>
|
||||
<axis xyz="1.0 -0.0 0.0"/>
|
||||
<limit upper="0.0" lower="0.0" effort="100" velocity="100"/>
|
||||
</joint>
|
||||
|
||||
</robot>
|
||||
0
unilabos/device_mesh/devices/liquid_transform_xyz/meshes/base_link.STL
Normal file → Executable file
0
unilabos/device_mesh/devices/liquid_transform_xyz/meshes/base_link.STL
Normal file → Executable file
0
unilabos/device_mesh/devices/liquid_transform_xyz/meshes/x_link.STL
Normal file → Executable file
0
unilabos/device_mesh/devices/liquid_transform_xyz/meshes/x_link.STL
Normal file → Executable file
0
unilabos/device_mesh/devices/liquid_transform_xyz/meshes/y_link.STL
Normal file → Executable file
0
unilabos/device_mesh/devices/liquid_transform_xyz/meshes/y_link.STL
Normal file → Executable file
0
unilabos/device_mesh/devices/liquid_transform_xyz/meshes/z_link.STL
Normal file → Executable file
0
unilabos/device_mesh/devices/liquid_transform_xyz/meshes/z_link.STL
Normal file → Executable file
@@ -14,6 +14,7 @@ from launch_ros.parameter_descriptions import ParameterFile
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
|
||||
|
||||
def get_pattern_matches(folder, pattern):
|
||||
"""Given all the files in the folder, find those that match the pattern.
|
||||
|
||||
@@ -51,7 +52,7 @@ class ResourceVisualization:
|
||||
self.launch_description = LaunchDescription()
|
||||
self.resource_dict = resource
|
||||
self.resource_model = {}
|
||||
self.resource_type = ['deck', 'plate', 'container']
|
||||
self.resource_type = ['deck', 'plate', 'container', 'tip_rack']
|
||||
self.mesh_path = Path(__file__).parent.absolute()
|
||||
self.enable_rviz = enable_rviz
|
||||
registry = lab_registry
|
||||
@@ -128,9 +129,9 @@ class ResourceVisualization:
|
||||
# if node["parent"] is not None:
|
||||
# new_dev.set("station_name", node["parent"]+'_')
|
||||
|
||||
new_dev.set("x",str(float(node["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["position"]["z"])/1000))
|
||||
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
||||
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
||||
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
||||
if "rotation" in node["config"]:
|
||||
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
|
||||
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
|
||||
@@ -140,7 +141,7 @@ class ResourceVisualization:
|
||||
new_dev.set(key, str(value))
|
||||
|
||||
# 添加ros2_controller
|
||||
if node['class'].startswith('moveit.'):
|
||||
if node['class'].find('moveit.')!= -1:
|
||||
new_include_controller = etree.SubElement(self.root, f"{{{xacro_uri}}}include")
|
||||
new_include_controller.set("filename", f"{str(self.mesh_path)}/devices/{model_config['mesh']}/config/macro.ros2_control.xacro")
|
||||
new_controller = etree.SubElement(self.root, f"{{{xacro_uri}}}{model_config['mesh']}_ros2_control")
|
||||
@@ -203,7 +204,24 @@ class ResourceVisualization:
|
||||
Returns:
|
||||
LaunchDescription: launch描述对象
|
||||
"""
|
||||
moveit_configs_utils_path = Path(get_package_share_directory("moveit_configs_utils"))
|
||||
# 检查ROS 2环境变量
|
||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||
raise OSError(
|
||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||
"请确保:\n"
|
||||
"1. 已安装ROS 2 (推荐使用 ros-humble-desktop-full)\n"
|
||||
"2. 已激活Conda环境: conda activate unilab\n"
|
||||
"3. 或手动source ROS 2 setup文件: source /opt/ros/humble/setup.bash\n"
|
||||
"4. 或者使用 --backend simple 参数跳过ROS依赖"
|
||||
)
|
||||
|
||||
try:
|
||||
moveit_configs_utils_path = Path(get_package_share_directory("moveit_configs_utils"))
|
||||
except Exception as e:
|
||||
raise OSError(
|
||||
f"无法找到moveit_configs_utils包。请确保ROS 2和MoveIt 2已正确安装。\n"
|
||||
f"原始错误: {e}"
|
||||
)
|
||||
default_folder = moveit_configs_utils_path / "default_configs"
|
||||
planning_pattern = re.compile("^(.*)_planning.yaml$")
|
||||
pipelines = []
|
||||
@@ -264,7 +282,8 @@ class ResourceVisualization:
|
||||
parameters=[
|
||||
{"robot_description": robot_description},
|
||||
ros2_controllers,
|
||||
]
|
||||
],
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||
@@ -274,6 +293,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
controllers.append(
|
||||
@@ -282,6 +302,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for i in controllers:
|
||||
@@ -300,7 +321,8 @@ class ResourceVisualization:
|
||||
'use_sim_time': False
|
||||
},
|
||||
# kinematics_dict
|
||||
]
|
||||
],
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -331,7 +353,8 @@ class ResourceVisualization:
|
||||
package='moveit_ros_move_group',
|
||||
executable='move_group',
|
||||
output='screen',
|
||||
parameters=moveit_params
|
||||
parameters=moveit_params,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -354,7 +377,8 @@ class ResourceVisualization:
|
||||
robot_description_planning,
|
||||
planning_pipelines,
|
||||
|
||||
]
|
||||
],
|
||||
env=dict(os.environ)
|
||||
)
|
||||
self.launch_description.add_action(rviz_node)
|
||||
|
||||
|
||||
BIN
unilabos/device_mesh/resources/bottle/meshes/bottle.stl
Normal file
BIN
unilabos/device_mesh/resources/bottle/meshes/bottle.stl
Normal file
Binary file not shown.
11
unilabos/device_mesh/resources/bottle/modal.xacro
Normal file
11
unilabos/device_mesh/resources/bottle/modal.xacro
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="bottle">
|
||||
<link name='bottle'>
|
||||
<visual name='visual'>
|
||||
<geometry>
|
||||
<mesh filename="meshes/bottle.stl"/>
|
||||
</geometry>
|
||||
<material name="clay" />
|
||||
</visual>
|
||||
</link>
|
||||
</robot>
|
||||
Binary file not shown.
11
unilabos/device_mesh/resources/bottle_container/modal.xacro
Normal file
11
unilabos/device_mesh/resources/bottle_container/modal.xacro
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="bottle_container">
|
||||
<link name='bottle_container'>
|
||||
<visual name='visual'>
|
||||
<geometry>
|
||||
<mesh filename="meshes/bottle_container.stl"/>
|
||||
</geometry>
|
||||
<material name="clay" />
|
||||
</visual>
|
||||
</link>
|
||||
</robot>
|
||||
BIN
unilabos/device_mesh/resources/plate_96/meshes/plate_96.stl
Normal file
BIN
unilabos/device_mesh/resources/plate_96/meshes/plate_96.stl
Normal file
Binary file not shown.
11
unilabos/device_mesh/resources/plate_96/modal.xacro
Normal file
11
unilabos/device_mesh/resources/plate_96/modal.xacro
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="plate_96">
|
||||
<link name='plate_96'>
|
||||
<visual name='visual'>
|
||||
<geometry>
|
||||
<mesh filename="meshes/plate_96.stl"/>
|
||||
</geometry>
|
||||
<material name="clay" />
|
||||
</visual>
|
||||
</link>
|
||||
</robot>
|
||||
BIN
unilabos/device_mesh/resources/tip/meshes/tip.stl
Normal file
BIN
unilabos/device_mesh/resources/tip/meshes/tip.stl
Normal file
Binary file not shown.
11
unilabos/device_mesh/resources/tip/modal.xacro
Normal file
11
unilabos/device_mesh/resources/tip/modal.xacro
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="tip">
|
||||
<link name='tip'>
|
||||
<visual name='visual'>
|
||||
<geometry>
|
||||
<mesh filename="meshes/tip.stl"/>
|
||||
</geometry>
|
||||
<material name="clay" />
|
||||
</visual>
|
||||
</link>
|
||||
</robot>
|
||||
Binary file not shown.
11
unilabos/device_mesh/resources/tiprack_box/modal.xacro
Normal file
11
unilabos/device_mesh/resources/tiprack_box/modal.xacro
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="tiprack_box">
|
||||
<link name='tiprack_box'>
|
||||
<visual name='visual'>
|
||||
<geometry>
|
||||
<mesh filename="meshes/tiprack_box.stl"/>
|
||||
</geometry>
|
||||
<material name="clay" />
|
||||
</visual>
|
||||
</link>
|
||||
</robot>
|
||||
BIN
unilabos/device_mesh/resources/tube/meshes/tube.stl
Normal file
BIN
unilabos/device_mesh/resources/tube/meshes/tube.stl
Normal file
Binary file not shown.
11
unilabos/device_mesh/resources/tube/modal.xacro
Normal file
11
unilabos/device_mesh/resources/tube/modal.xacro
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="tube">
|
||||
<link name='tube'>
|
||||
<visual name='visual'>
|
||||
<geometry>
|
||||
<mesh filename="meshes/tube.stl"/>
|
||||
</geometry>
|
||||
<material name="clay" />
|
||||
</visual>
|
||||
</link>
|
||||
</robot>
|
||||
Binary file not shown.
11
unilabos/device_mesh/resources/tube_container/modal.xacro
Normal file
11
unilabos/device_mesh/resources/tube_container/modal.xacro
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" ?>
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="tube_container">
|
||||
<link name='tube_container'>
|
||||
<visual name='visual'>
|
||||
<geometry>
|
||||
<mesh filename="meshes/tube_container.stl"/>
|
||||
</geometry>
|
||||
<material name="clay" />
|
||||
</visual>
|
||||
</link>
|
||||
</robot>
|
||||
@@ -5,11 +5,13 @@ Panels:
|
||||
Property Tree Widget:
|
||||
Expanded:
|
||||
- /TF1/Tree1
|
||||
- /PlanningScene1
|
||||
- /PlanningScene1/Scene Geometry1
|
||||
- /MotionPlanning1/Scene Geometry1
|
||||
- /MotionPlanning1/Scene Robot1
|
||||
- /MotionPlanning1/Planning Request1
|
||||
Splitter Ratio: 0.5016146302223206
|
||||
Tree Height: 1112
|
||||
Tree Height: 563
|
||||
- Class: rviz_common/Selection
|
||||
Name: Selection
|
||||
- Class: rviz_common/Tool Properties
|
||||
@@ -91,7 +93,7 @@ Visualization Manager:
|
||||
Planning Scene Topic: /monitored_planning_scene
|
||||
Robot Description: robot_description
|
||||
Scene Geometry:
|
||||
Scene Alpha: 0.8999999761581421
|
||||
Scene Alpha: 1
|
||||
Scene Color: 50; 230; 50
|
||||
Scene Display Time: 0.009999999776482582
|
||||
Show Scene Geometry: true
|
||||
@@ -567,25 +569,25 @@ Visualization Manager:
|
||||
Pitch: 0.4297958016395569
|
||||
Target Frame: <Fixed Frame>
|
||||
Value: Orbit (rviz)
|
||||
Yaw: 0.3525616228580475
|
||||
Yaw: 0.36756160855293274
|
||||
Saved: ~
|
||||
Window Geometry:
|
||||
Displays:
|
||||
collapsed: false
|
||||
Height: 2032
|
||||
Height: 1088
|
||||
Hide Left Dock: false
|
||||
Hide Right Dock: true
|
||||
MotionPlanning:
|
||||
collapsed: true
|
||||
collapsed: false
|
||||
MotionPlanning - Trajectory Slider:
|
||||
collapsed: false
|
||||
QMainWindow State: 000000ff00000000fd0000000400000000000003a30000079bfc020000000bfb0000001200530065006c0065006300740069006f006e00000001e10000009b000000b000fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c0061007900730100000027000004c60000018200fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb000000280020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000000000000000fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000007a00fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e006701000004f9000002c9000002b800ffffff000000010000010f00000387fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073000000003b000003870000013200fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d0065010000000000000450000000000000000000000bc50000079b00000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000
|
||||
QMainWindow State: 000000ff00000000fd0000000400000000000003a30000040bfc020000000bfb0000001200530065006c0065006300740069006f006e00000001e10000009b0000005c00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073010000001700000271000000ca00fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb000000280020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000000000000000fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000004200fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e0067010000028e000001940000018900ffffff000000010000010f00000387fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073000000003b00000387000000a600fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d00650100000000000004500000000000000000000004110000040b00000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000
|
||||
Selection:
|
||||
collapsed: false
|
||||
Tool Properties:
|
||||
collapsed: false
|
||||
Views:
|
||||
collapsed: true
|
||||
Width: 3956
|
||||
X: 140
|
||||
Y: 54
|
||||
Width: 1978
|
||||
X: 70
|
||||
Y: 27
|
||||
|
||||
200
unilabos/devices/Qone_nmr/QOne_NMR_User_Guide.md
Normal file
200
unilabos/devices/Qone_nmr/QOne_NMR_User_Guide.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# QOne NMR 用户指南
|
||||
|
||||
## 概述
|
||||
|
||||
Qone NMR 设备支持多字符串数据处理功能。该设备可以接收包含多个字符串的输入数据,并将每个字符串转换为独立的 TXT 文件,支持灵活的数据格式化和输出。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **多字符串处理**: 支持逗号分隔或换行分隔的多个字符串输入
|
||||
- **自动文件生成**: 每个输入字符串生成一个对应的 TXT 文件
|
||||
- **文件夹监督**: 自动监督指定目录,检测新内容生成
|
||||
- **错误处理**: 完善的输入验证和错误处理机制
|
||||
|
||||
## 参数说明
|
||||
|
||||
### 输入参数
|
||||
|
||||
- **string** (str): 包含多个字符串的输入数据,支持格式:
|
||||
- **逗号分隔**: `"字符串1, 字符串2, 字符串3"`
|
||||
|
||||
### 输出参数
|
||||
|
||||
- **return_info** (str): 处理结果信息,包含监督功能的执行结果
|
||||
- **success** (bool): 处理是否成功
|
||||
- **files_generated** (int): 生成的 TXT 文件数量
|
||||
|
||||
## 输入数据格式
|
||||
|
||||
### 支持的输入格式
|
||||
|
||||
1. **逗号分隔格式**:
|
||||
```
|
||||
"A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END"
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
### 数据项格式
|
||||
|
||||
每个字符串内的数据项应该用空格分隔,例如:
|
||||
- `A 1 B 2 C 3 D 4 E 5 F 6 G 7 H 8 END`
|
||||
- `Sample 001 Method A Volume 10.5 Temp 25.0`
|
||||
|
||||
## 输出文件说明
|
||||
|
||||
### 文件命名
|
||||
|
||||
生成的 TXT 文件将按照row_字符串顺序命名,例如:
|
||||
- `row_1.txt`
|
||||
- `row_2.txt`
|
||||
|
||||
### 文件格式
|
||||
|
||||
每个 TXT 文件包含对应字符串的格式化数据,格式为:
|
||||
```
|
||||
A 1
|
||||
B 2
|
||||
C 3
|
||||
D 4
|
||||
E 5
|
||||
F 6
|
||||
G 7
|
||||
H 8
|
||||
END
|
||||
```
|
||||
|
||||
### 输出目录
|
||||
|
||||
默认输出目录为 `D:/setup/txt`,可以在 `device.json` 中配置 `output_dir` 参数。
|
||||
|
||||
## 文件夹监督功能
|
||||
|
||||
### 监督机制
|
||||
|
||||
设备在完成字符串到TXT文件的转换后,会自动启动文件夹监督功能:
|
||||
|
||||
- **监督目录**: 默认监督 `D:/Data/MyPC/Automation` 目录
|
||||
- **检查间隔**: 每60秒检查一次新生成的.nmr文件
|
||||
- **检测内容**: 新文件生成或现有文件大小变化
|
||||
- **停止条件**: 连续三次文件大小没有变化,则检测完成
|
||||
|
||||
## 文件夹监督功能详细说明
|
||||
|
||||
Oxford NMR设备驱动集成了智能文件夹监督功能,用于监测.nmr结果文件的生成完成状态。该功能通过监测文件大小变化来判断文件是否已完成写入。
|
||||
|
||||
### 工作机制
|
||||
|
||||
1. **文件大小监测**: 监督功能专门监测指定目录中新生成的.nmr文件的大小变化
|
||||
2. **稳定性检测**: 当文件大小在连续多次检查中保持不变时,认为文件已完成写入
|
||||
3. **批量处理支持**: 根据输入的.txt文件数量,自动确定需要监测的.nmr文件数量
|
||||
4. **实时反馈**: 提供详细的监测进度和文件状态信息
|
||||
5. **自动停止**: 当所有期望的.nmr文件都达到稳定状态时,监督功能自动停止,start函数执行完毕
|
||||
|
||||
### 配置参数
|
||||
|
||||
可以通过`device.json`配置文件调整监督功能的行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"output_dir": "D:/setup/txt",
|
||||
"monitor_dir": "D:\\Data\\MyPC\\Automation",
|
||||
"stability_checks": 3,
|
||||
"check_interval": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `monitor_dir`: 监督的目录路径,默认为`D:\Data\MyPC\Automation`
|
||||
- `stability_checks`: 文件大小稳定性检查次数,默认为3次(连续2次检查大小不变则认为文件完成)
|
||||
- `check_interval`: 检查间隔时间(秒),默认为60秒
|
||||
|
||||
### 监测逻辑
|
||||
|
||||
1. **初始状态记录**: 记录监督开始时目录中已存在的.nmr文件及其大小
|
||||
2. **新文件检测**: 持续检测新生成的.nmr文件
|
||||
3. **大小变化跟踪**: 为每个新文件维护大小变化历史记录
|
||||
4. **稳定性判断**: 当文件大小在连续`stability_checks`次检查中保持不变且大小大于0时,认为文件完成
|
||||
5. **完成条件**: 当达到期望数量的.nmr文件都完成时,监督功能结束
|
||||
|
||||
### 配置监督目录
|
||||
|
||||
可以在 `device.json` 中配置 `monitor_dir` 参数来指定监督目录:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"output_dir": "D:/setup/txt",
|
||||
"monitor_dir": "D:/Data/MyPC/Automation"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1: 基本多字符串处理
|
||||
|
||||
```python
|
||||
from unilabos.devices.Qone_nmr.Qone_nmr import Qone_nmr
|
||||
|
||||
# 创建设备实例
|
||||
device = Qone_nmr(output_directory="D:/setup/txt")
|
||||
|
||||
# 输入多个字符串(逗号分隔)
|
||||
input_data = "A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END"
|
||||
|
||||
# 处理数据
|
||||
result = device.start(string=input_data)
|
||||
|
||||
print(f"处理结果: {result}")
|
||||
# 输出: {'return_info': 'Oxford NMR处理完成: 已生成 3 个 txt 文件,保存在: ./output | 监督完成: 成功检测到 3 个.nmr文件已完成生成', 'success': True, 'files_generated': 3}
|
||||
|
||||
### 输出示例
|
||||
|
||||
当设备成功处理输入并完成文件监督后,会返回如下格式的结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"return_info": "Oxford NMR处理完成: 已生成 3 个 txt 文件,保存在: D:/setup/txt | 监督完成: 成功检测到 3 个.nmr文件已完成生成,start函数执行完毕",
|
||||
"success": true,
|
||||
"files_generated": 3
|
||||
}
|
||||
```
|
||||
|
||||
监督过程中的日志输出示例:
|
||||
```
|
||||
[INFO] 开始监督目录: D:/Data/MyPC/Automation,检查间隔: 30秒,期望.nmr文件数量: 3,稳定性检查: 2次
|
||||
[INFO] 初始状态: 0 个.nmr文件
|
||||
[INFO] 检测到 3 个新.nmr文件,还需要 0 个...
|
||||
[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample1.nmr (当前: 1024 字节, 检查次数: 1/3)
|
||||
[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample2.nmr (当前: 2048 字节, 检查次数: 1/3)
|
||||
[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample3.nmr (当前: 1536 字节, 检查次数: 1/3)
|
||||
[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample1.nmr (大小: 1024 字节)
|
||||
[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample2.nmr (大小: 2048 字节)
|
||||
[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample3.nmr (大小: 1536 字节)
|
||||
[INFO] 所有期望的.nmr文件都已完成生成! 完成文件数: 3/3
|
||||
[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample1.nmr (最终大小: 1024 字节)
|
||||
[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample2.nmr (最终大小: 2048 字节)
|
||||
[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample3.nmr (最终大小: 1536 字节)
|
||||
[INFO] 停止文件夹监测,所有文件已完成
|
||||
```
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
设备具有完善的错误处理机制:
|
||||
|
||||
- **空输入**: 如果输入为空或 None,返回错误信息
|
||||
- **无效格式**: 如果输入格式不正确,返回相应错误
|
||||
- **文件系统错误**: 如果输出目录不存在或无权限,返回错误信息
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **目录权限**: 确保监督目录具有读取权限,以便设备能够检测文件变化
|
||||
2. **文件大小监测**: 监督功能现在基于文件大小变化来判断.nmr文件是否完成,而不是简单的文件存在性检查
|
||||
3. **稳定性检查**: 文件大小需要在连续多次检查中保持不变才被认为完成,默认为3次检查
|
||||
4. **自动停止**: 监督功能会在检测到期望数量的.nmr文件都达到稳定状态后自动停止,避免无限循环
|
||||
5. **配置灵活性**: 可以通过`device.json`调整稳定性检查次数和检查间隔,以适应不同的使用场景
|
||||
6. **文件类型**: 监督功能专门针对.nmr文件,忽略其他类型的文件变化
|
||||
7. **批量处理**: 支持同时监测多个.nmr文件的完成状态,适合批量处理场景
|
||||
382
unilabos/devices/Qone_nmr/Qone_nmr.py
Normal file
382
unilabos/devices/Qone_nmr/Qone_nmr.py
Normal file
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Oxford NMR Device Driver for Uni-Lab OS
|
||||
|
||||
支持Oxford NMR设备的CSV字符串到TXT文件转换功能。
|
||||
通过ROS2动作接口接收CSV字符串,批量生成TXT文件到指定目录。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
class UniversalDriver:
|
||||
"""Fallback UniversalDriver for standalone testing"""
|
||||
def __init__(self):
|
||||
self.success = False
|
||||
|
||||
class Qone_nmr(UniversalDriver):
|
||||
"""Oxford NMR Device Driver
|
||||
|
||||
支持CSV字符串到TXT文件的批量转换功能。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize the Oxford NMR driver
|
||||
|
||||
Args:
|
||||
**kwargs: Device-specific configuration parameters
|
||||
- config: Configuration dictionary containing output_dir
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# Device configuration
|
||||
self.config = kwargs
|
||||
config_dict = kwargs.get('config', {})
|
||||
|
||||
# 设置输出目录,优先使用配置中的output_dir,否则使用默认值
|
||||
self.output_directory = "D:\\setup\\txt" # 默认输出目录
|
||||
if config_dict and 'output_dir' in config_dict:
|
||||
self.output_directory = config_dict['output_dir']
|
||||
|
||||
# 设置监督目录,优先使用配置中的monitor_dir,否则使用默认值
|
||||
self.monitor_directory = "D:/Data/MyPC/Automation" # 默认监督目录
|
||||
if config_dict and 'monitor_dir' in config_dict:
|
||||
self.monitor_directory = config_dict['monitor_dir']
|
||||
|
||||
# 设置文件大小稳定性检查参数
|
||||
self.stability_checks = 3 # 默认稳定性检查次数
|
||||
if config_dict and 'stability_checks' in config_dict:
|
||||
self.stability_checks = config_dict['stability_checks']
|
||||
|
||||
# 设置检查间隔时间
|
||||
self.check_interval = 60 # 默认检查间隔(秒)
|
||||
if config_dict and 'check_interval' in config_dict:
|
||||
self.check_interval = config_dict['check_interval']
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(self.output_directory, exist_ok=True)
|
||||
|
||||
# ROS节点引用,由框架设置
|
||||
self._ros_node = None
|
||||
|
||||
# ROS2 action result properties
|
||||
self.success = False
|
||||
self.return_info = ""
|
||||
|
||||
# Setup logging
|
||||
self.logger = logging.getLogger(f"Qone_nmr-{kwargs.get('id', 'unknown')}")
|
||||
self.logger.info(f"Oxford NMR driver initialized with output directory: {self.output_directory}")
|
||||
self.logger.info(f"Monitor directory set to: {self.monitor_directory}")
|
||||
self.logger.info(f"Stability checks: {self.stability_checks}, Check interval: {self.check_interval}s")
|
||||
|
||||
def post_init(self, ros_node):
|
||||
"""ROS节点初始化后的回调方法
|
||||
|
||||
Args:
|
||||
ros_node: ROS节点实例
|
||||
"""
|
||||
self._ros_node = ros_node
|
||||
ros_node.lab_logger().info(f"Oxford NMR设备初始化完成,输出目录: {self.output_directory}")
|
||||
|
||||
def get_status(self) -> str:
|
||||
"""获取设备状态
|
||||
|
||||
Returns:
|
||||
str: 设备状态 (Idle|Offline|Error|Busy|Unknown)
|
||||
"""
|
||||
return "Idle" # NMR设备始终处于空闲状态,等待处理请求
|
||||
|
||||
def strings_to_txt(self, string_list, output_dir=None, txt_encoding="utf-8"):
|
||||
"""
|
||||
将字符串列表写入多个 txt 文件
|
||||
string_list: ["A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END", ...]
|
||||
|
||||
Args:
|
||||
string_list: 字符串列表
|
||||
output_dir: 输出目录(如果未指定,使用self.output_directory)
|
||||
txt_encoding: 文件编码
|
||||
|
||||
Returns:
|
||||
int: 生成的文件数量
|
||||
"""
|
||||
# 使用指定的输出目录或默认目录
|
||||
target_dir = output_dir if output_dir else self.output_directory
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
self.logger.info(f"开始生成文件到目录: {target_dir}")
|
||||
|
||||
for i, s in enumerate(string_list, start=1):
|
||||
try:
|
||||
# 去掉开头结尾的引号(如果有)
|
||||
s = s.strip('"').strip("'")
|
||||
|
||||
# 拆分字符串
|
||||
parts = s.split()
|
||||
|
||||
# 按两两一组重新排版为多行
|
||||
txt_lines = []
|
||||
for j in range(0, len(parts) - 1, 2):
|
||||
txt_lines.append("{} {}".format(parts[j], parts[j+1]))
|
||||
txt_lines.append("END")
|
||||
|
||||
txt_content = "\n".join(txt_lines)
|
||||
|
||||
# 生成文件名(row_1.txt, row_2.txt, ...)
|
||||
file_name = "row_{}.txt".format(i)
|
||||
out_path = os.path.join(target_dir, file_name)
|
||||
|
||||
with open(out_path, "w", encoding=txt_encoding) as f:
|
||||
f.write(txt_content)
|
||||
|
||||
self.logger.info(f"成功生成文件: {file_name}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"处理第{i}个字符串时出错: {str(e)}")
|
||||
raise
|
||||
|
||||
return len(string_list) # 返回生成文件数量
|
||||
|
||||
def monitor_folder_for_new_content(self, monitor_dir=None, check_interval=60, expected_count=1, stability_checks=3):
|
||||
"""监督指定文件夹中.nmr文件的大小变化,当文件大小稳定时认为文件完成
|
||||
|
||||
Args:
|
||||
monitor_dir (str): 要监督的目录路径,如果未指定则使用self.monitor_directory
|
||||
check_interval (int): 检查间隔时间(秒),默认60秒
|
||||
expected_count (int): 期望生成的.nmr文件数量,默认1个
|
||||
stability_checks (int): 文件大小稳定性检查次数,默认3次
|
||||
|
||||
Returns:
|
||||
bool: 如果检测到期望数量的.nmr文件且大小稳定返回True,否则返回False
|
||||
"""
|
||||
target_dir = monitor_dir if monitor_dir else self.monitor_directory
|
||||
|
||||
# 确保监督目录存在
|
||||
if not os.path.exists(target_dir):
|
||||
self.logger.warning(f"监督目录不存在: {target_dir}")
|
||||
return False
|
||||
|
||||
self.logger.info(f"开始监督目录: {target_dir},检查间隔: {check_interval}秒,期望.nmr文件数量: {expected_count},稳定性检查: {stability_checks}次")
|
||||
|
||||
# 记录初始的.nmr文件及其大小
|
||||
initial_nmr_files = {}
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(target_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith('.nmr'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
initial_nmr_files[file_path] = file_size
|
||||
except OSError:
|
||||
pass # 忽略无法访问的文件
|
||||
except Exception as e:
|
||||
self.logger.error(f"读取初始目录状态失败: {str(e)}")
|
||||
return False
|
||||
|
||||
self.logger.info(f"初始状态: {len(initial_nmr_files)} 个.nmr文件")
|
||||
|
||||
# 跟踪新文件的大小变化历史
|
||||
new_files_size_history = {}
|
||||
completed_files = set()
|
||||
|
||||
# 开始监督循环
|
||||
while True:
|
||||
time.sleep(check_interval)
|
||||
|
||||
current_nmr_files = {}
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(target_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith('.nmr'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
current_nmr_files[file_path] = file_size
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 找出新生成的.nmr文件
|
||||
new_nmr_files = set(current_nmr_files.keys()) - set(initial_nmr_files.keys())
|
||||
|
||||
if len(new_nmr_files) < expected_count:
|
||||
self.logger.info(f"检测到 {len(new_nmr_files)} 个新.nmr文件,还需要 {expected_count - len(new_nmr_files)} 个...")
|
||||
continue
|
||||
|
||||
# 检查新文件的大小稳定性
|
||||
for file_path in new_nmr_files:
|
||||
if file_path in completed_files:
|
||||
continue
|
||||
|
||||
current_size = current_nmr_files.get(file_path, 0)
|
||||
|
||||
# 初始化文件大小历史记录
|
||||
if file_path not in new_files_size_history:
|
||||
new_files_size_history[file_path] = []
|
||||
|
||||
# 记录当前大小
|
||||
new_files_size_history[file_path].append(current_size)
|
||||
|
||||
# 保持历史记录长度不超过稳定性检查次数
|
||||
if len(new_files_size_history[file_path]) > stability_checks:
|
||||
new_files_size_history[file_path] = new_files_size_history[file_path][-stability_checks:]
|
||||
|
||||
# 检查大小是否稳定
|
||||
size_history = new_files_size_history[file_path]
|
||||
if len(size_history) >= stability_checks:
|
||||
# 检查最近几次的大小是否相同且不为0
|
||||
if len(set(size_history[-stability_checks:])) == 1 and size_history[-1] > 0:
|
||||
self.logger.info(f"文件大小已稳定: {file_path} (大小: {current_size} 字节)")
|
||||
completed_files.add(file_path)
|
||||
else:
|
||||
self.logger.debug(f"文件大小仍在变化: {file_path} (当前: {current_size} 字节, 历史: {size_history[-3:]})")
|
||||
else:
|
||||
self.logger.debug(f"文件大小监测中: {file_path} (当前: {current_size} 字节, 检查次数: {len(size_history)}/{stability_checks})")
|
||||
|
||||
# 检查是否所有期望的文件都已完成
|
||||
if len(completed_files) >= expected_count:
|
||||
self.logger.info(f"所有期望的.nmr文件都已完成生成! 完成文件数: {len(completed_files)}/{expected_count}")
|
||||
for completed_file in list(completed_files)[:expected_count]:
|
||||
final_size = current_nmr_files.get(completed_file, 0)
|
||||
self.logger.info(f"完成的.nmr文件: {completed_file} (最终大小: {final_size} 字节)")
|
||||
self.logger.info("停止文件夹监测,所有文件已完成")
|
||||
return True
|
||||
else:
|
||||
self.logger.info(f"已完成 {len(completed_files)} 个文件,还需要 {expected_count - len(completed_files)} 个文件完成...")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"监督过程中出错: {str(e)}")
|
||||
return False
|
||||
|
||||
def start(self, string: str = None) -> dict:
|
||||
"""使用字符串列表启动TXT文件生成(支持ROS2动作调用)
|
||||
|
||||
Args:
|
||||
string (str): 包含多个字符串的输入数据,支持两种格式:
|
||||
1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30"
|
||||
2. 换行分隔:如 "A 1 B 2 C 3\nX 10 Y 20 Z 30"
|
||||
|
||||
Returns:
|
||||
dict: ROS2动作结果格式 {"return_info": str, "success": bool, "files_generated": int}
|
||||
"""
|
||||
try:
|
||||
if string is None or string.strip() == "":
|
||||
error_msg = "未提供字符串参数或参数为空"
|
||||
self.logger.error(error_msg)
|
||||
return {"return_info": error_msg, "success": False, "files_generated": 0}
|
||||
|
||||
self.logger.info(f"开始处理字符串数据,长度: {len(string)} 字符")
|
||||
|
||||
# 支持两种分隔方式:逗号分隔或换行分隔
|
||||
string_list = []
|
||||
|
||||
# 首先尝试逗号分隔
|
||||
if ',' in string:
|
||||
string_list = [item.strip() for item in string.split(',') if item.strip()]
|
||||
else:
|
||||
# 如果没有逗号,则按换行分隔
|
||||
string_list = [line.strip() for line in string.strip().split('\n') if line.strip()]
|
||||
|
||||
if not string_list:
|
||||
error_msg = "输入字符串解析后为空"
|
||||
self.logger.error(error_msg)
|
||||
return {"return_info": error_msg, "success": False, "files_generated": 0}
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(self.output_directory, exist_ok=True)
|
||||
|
||||
# 使用strings_to_txt函数生成TXT文件
|
||||
file_count = self.strings_to_txt(
|
||||
string_list=string_list,
|
||||
output_dir=self.output_directory,
|
||||
txt_encoding='utf-8'
|
||||
)
|
||||
|
||||
success_msg = f"Oxford NMR处理完成: 已生成 {file_count} 个 txt 文件,保存在: {self.output_directory}"
|
||||
self.logger.info(success_msg)
|
||||
|
||||
# 在string转txt完成后,启动文件夹监督功能
|
||||
self.logger.info(f"开始启动文件夹监督功能,期望生成 {file_count} 个.nmr文件...")
|
||||
monitor_result = self.monitor_folder_for_new_content(
|
||||
expected_count=file_count,
|
||||
check_interval=self.check_interval,
|
||||
stability_checks=self.stability_checks
|
||||
)
|
||||
|
||||
if monitor_result:
|
||||
success_msg += f" | 监督完成: 成功检测到 {file_count} 个.nmr文件已完成生成,start函数执行完毕"
|
||||
else:
|
||||
success_msg += f" | 监督结束: 监督过程中断或失败,start函数执行完毕"
|
||||
|
||||
return {"return_info": success_msg, "success": True, "files_generated": file_count}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"字符串处理失败: {str(e)}"
|
||||
self.logger.error(error_msg)
|
||||
return {"return_info": error_msg, "success": False, "files_generated": 0}
|
||||
|
||||
|
||||
|
||||
def test_qone_nmr():
|
||||
"""测试Qone_nmr设备的字符串处理功能"""
|
||||
try:
|
||||
# 配置日志输出
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger("Qone_nmr_test")
|
||||
|
||||
logger.info("开始测试Qone_nmr设备...")
|
||||
|
||||
# 创建设备实例,使用正确的配置格式
|
||||
device = Qone_nmr(config={'output_dir': "D:\\setup\\txt"})
|
||||
logger.info(f"设备初始化完成,输出目录: {device.output_directory}")
|
||||
|
||||
# 测试数据:多个字符串,逗号分隔
|
||||
test_strings = "A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END"
|
||||
logger.info(f"测试输入: {test_strings}")
|
||||
|
||||
# 确保输出目录存在
|
||||
if not os.path.exists(device.output_directory):
|
||||
os.makedirs(device.output_directory, exist_ok=True)
|
||||
logger.info(f"创建输出目录: {device.output_directory}")
|
||||
|
||||
# 调用start方法
|
||||
result = device.start(string=test_strings)
|
||||
logger.info(f"处理结果: {result}")
|
||||
|
||||
# 显示生成的文件内容
|
||||
if result.get('success', False):
|
||||
output_dir = device.output_directory
|
||||
if os.path.exists(output_dir):
|
||||
txt_files = [f for f in os.listdir(output_dir) if f.endswith('.txt')]
|
||||
logger.info(f"生成的文件数量: {len(txt_files)}")
|
||||
for i, filename in enumerate(txt_files[:2]): # 只显示前2个文件
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
logger.info(f"文件 {i+1}: {filename}")
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
logger.info(f"内容:\n{content}")
|
||||
|
||||
logger.info("测试完成!")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"测试过程中出现错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"return_info": f"测试失败: {str(e)}", "success": False, "files_generated": 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_qone_nmr()
|
||||
25
unilabos/devices/Qone_nmr/device.json
Normal file
25
unilabos/devices/Qone_nmr/device.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "Qone_nmr_device",
|
||||
"name": "Qone_NMR_Device",
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "Qone_nmr",
|
||||
"position": {
|
||||
"x": 620.6111111111111,
|
||||
"y": 171,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"output_dir": "D:\\setup\\txt",
|
||||
"monitor_dir": "D:\\Data\\MyPC\\Automation",
|
||||
"stability_checks": 3,
|
||||
"check_interval": 60
|
||||
},
|
||||
"data": {},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
4
unilabos/devices/Qone_nmr/samples.csv
Normal file
4
unilabos/devices/Qone_nmr/samples.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
USERNAME,SLOT,EXPNAME,FILE,SOLVENT,TEMPLATE,TITLE
|
||||
User,SLOT,Name,No.,SOLVENT,Experiment,TITLE
|
||||
用户名,进样器孔位,实验任务的名字,保存文件的名字,溶剂(按照实验的要求),模板(按照实验的要求,指定测试的元素),标题
|
||||
admin,18,11LiDFOB,LiDFOB-11B,DMSO,B11,11LiDFOB_400MHz
|
||||
|
61
unilabos/devices/cytomat/cytomat.py
Normal file
61
unilabos/devices/cytomat/cytomat.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import serial
|
||||
import time
|
||||
|
||||
ser = serial.Serial(
|
||||
port="COM18",
|
||||
baudrate=9600,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=15,
|
||||
|
||||
def send_cmd(cmd: str, wait: float = 1.0) -> str:
|
||||
"""向 Cytomat 发送一行命令并打印/返回响应。"""
|
||||
print(f">>> {cmd}")
|
||||
ser.write((cmd + "\r").encode("ascii"))
|
||||
time.sleep(wait)
|
||||
resp = ser.read_all().decode("ascii", errors="ignore").strip()
|
||||
print(f"<<< {resp or '<no response>'}")
|
||||
return resp
|
||||
|
||||
def initialize():
|
||||
"""设备初始化 (ll:in)。"""
|
||||
return send_cmd("ll:in")
|
||||
|
||||
def wp_to_storage(pos: int):
|
||||
"""WP → 库位。pos: 1–9999 绝对地址。"""
|
||||
return send_cmd(f"mv:ws {pos:04d}")
|
||||
|
||||
def storage_to_tfs(stacker: int, level: int):
|
||||
"""库位 → TFS1。"""
|
||||
return send_cmd(f"mv:st {stacker:02d} {level:02d}")
|
||||
|
||||
def get_basic_state():
|
||||
"""查询 Basic State Register。"""
|
||||
return send_cmd("ch:bs")
|
||||
|
||||
def set_pitch(stacker: int, pitch_mm: int):
|
||||
"""设置单个 stacker 的层间距(mm)。"""
|
||||
return send_cmd(f"se:cs {stacker:02d} {pitch_mm}")
|
||||
|
||||
def tfs_to_storage(stacker: int, level: int):
|
||||
"""TFS1 → 库位。"""
|
||||
return send_cmd(f"mv:ts {stacker:02d} {level:02d}")
|
||||
|
||||
# ---------- 示例工作流 ----------
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
if not ser.is_open:
|
||||
ser.open()
|
||||
initialize()
|
||||
wp_to_storage(10)
|
||||
storage_to_tfs(17, 3)
|
||||
get_basic_state()
|
||||
tfs_to_storage(7, 5)
|
||||
|
||||
except Exception as exc:
|
||||
print("Error:", exc)
|
||||
finally:
|
||||
ser.close()
|
||||
print("Done.")
|
||||
|
||||
@@ -405,9 +405,19 @@ class RunningResultChecker(DriverChecker):
|
||||
for i in range(self.driver._finished, temp):
|
||||
sample_id = self.driver._get_resource_sample_id(self.driver._wf_name, i) # 从0开始计数
|
||||
pdf, txt = self.driver.get_data_file(i + 1)
|
||||
device_id = self.driver.device_id if hasattr(self.driver, "device_id") else "default"
|
||||
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example", device_id=device_id)
|
||||
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result", device_id=device_id)
|
||||
# 使用新的OSS上传接口,传入driver_name和exp_type
|
||||
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
|
||||
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
|
||||
|
||||
if pdf_result["success"]:
|
||||
print(f"PDF上传成功: {pdf_result['oss_path']}")
|
||||
else:
|
||||
print(f"PDF上传失败: {pdf_result['original_path']}")
|
||||
|
||||
if txt_result["success"]:
|
||||
print(f"TXT上传成功: {txt_result['oss_path']}")
|
||||
else:
|
||||
print(f"TXT上传失败: {txt_result['original_path']}")
|
||||
# self.driver.extract_data_from_txt()
|
||||
except Exception as ex:
|
||||
self.driver._finished = 0
|
||||
@@ -456,8 +466,12 @@ if __name__ == "__main__":
|
||||
}
|
||||
sample_id = obj._get_resource_sample_id("test", 0)
|
||||
pdf, txt = obj.get_data_file("1", after_time=datetime(2024, 11, 6, 19, 3, 6))
|
||||
oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example")
|
||||
oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result")
|
||||
# 使用新的OSS上传接口,传入driver_name和exp_type
|
||||
pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis")
|
||||
txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result")
|
||||
|
||||
print(f"PDF上传结果: {pdf_result}")
|
||||
print(f"TXT上传结果: {txt_result}")
|
||||
# driver = HPLCDriver()
|
||||
# for i in range(10000):
|
||||
# print({k: v for k, v in driver._device_status.items() if isinstance(v, str)})
|
||||
|
||||
0
unilabos/devices/laiyu_liquid_test/__init__.py
Normal file
0
unilabos/devices/laiyu_liquid_test/__init__.py
Normal file
138
unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py
Normal file
138
unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py
Normal file
@@ -0,0 +1,138 @@
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from xyz_stepper_driver import ModbusRTUTransport, ModbusClient, XYZStepperController, MotorStatus
|
||||
|
||||
# ========== 日志配置 ==========
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("XYZ_Debug")
|
||||
|
||||
|
||||
def create_controller(port: str = "/dev/ttyUSB1", baudrate: int = 115200) -> XYZStepperController:
|
||||
"""
|
||||
初始化通信层与三轴控制器
|
||||
"""
|
||||
logger.info(f"🔧 初始化控制器: {port} @ {baudrate}bps")
|
||||
transport = ModbusRTUTransport(port=port, baudrate=baudrate)
|
||||
transport.open()
|
||||
client = ModbusClient(transport)
|
||||
return XYZStepperController(client=client, port=port, baudrate=baudrate)
|
||||
|
||||
|
||||
def load_existing_soft_zero(ctrl: XYZStepperController, path: str = "work_origin.json") -> bool:
|
||||
"""
|
||||
如果已存在软零点文件则加载,否则返回 False
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
logger.warning("⚠ 未找到已有软零点文件,将等待人工定义新零点。")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
origin = data.get("work_origin_steps", {})
|
||||
ctrl.work_origin_steps = origin
|
||||
ctrl.is_homed = True
|
||||
logger.info(f"✔ 已加载软零点文件:{path}")
|
||||
logger.info(f"当前软零点步数: {origin}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"读取软零点文件失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_enable_axis(ctrl: XYZStepperController):
|
||||
"""
|
||||
依次使能 X / Y / Z 三轴
|
||||
"""
|
||||
logger.info("=== 测试各轴使能 ===")
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
try:
|
||||
result = ctrl.enable(axis, True)
|
||||
if result:
|
||||
vals = ctrl.get_status(axis)
|
||||
st = MotorStatus(vals[3])
|
||||
logger.info(f"{axis} 轴使能成功,当前状态: {st.name}")
|
||||
else:
|
||||
logger.error(f"{axis} 轴使能失败")
|
||||
except Exception as e:
|
||||
logger.error(f"{axis} 轴使能异常: {e}")
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def test_status_read(ctrl: XYZStepperController):
|
||||
"""
|
||||
读取各轴当前状态(调试)
|
||||
"""
|
||||
logger.info("=== 当前各轴状态 ===")
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
try:
|
||||
vals = ctrl.get_status(axis)
|
||||
st = MotorStatus(vals[3])
|
||||
logger.info(
|
||||
f"{axis}: steps={vals[0]}, speed={vals[1]}, "
|
||||
f"current={vals[2]}, status={st.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取 {axis} 状态失败: {e}")
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def redefine_soft_zero(ctrl: XYZStepperController):
|
||||
"""
|
||||
手动重新定义软零点
|
||||
"""
|
||||
logger.info("=== ⚙️ 重新定义软零点 ===")
|
||||
ctrl.define_current_as_zero("work_origin.json")
|
||||
logger.info("✅ 新软零点已写入 work_origin.json")
|
||||
|
||||
|
||||
def test_soft_zero_move(ctrl: XYZStepperController):
|
||||
"""
|
||||
以软零点为基准执行三轴运动测试
|
||||
"""
|
||||
logger.info("=== 测试软零点相对运动 ===")
|
||||
ctrl.move_xyz_work(x=100.0, y=100.0, z=40.0, speed=100, acc=800)
|
||||
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
ctrl.wait_complete(axis)
|
||||
|
||||
test_status_read(ctrl)
|
||||
logger.info("✅ 软零点运动测试完成")
|
||||
|
||||
|
||||
def main():
|
||||
ctrl = create_controller(port="/dev/ttyUSB1", baudrate=115200)
|
||||
|
||||
try:
|
||||
test_enable_axis(ctrl)
|
||||
test_status_read(ctrl)
|
||||
|
||||
# === 初始化或加载软零点 ===
|
||||
loaded = load_existing_soft_zero(ctrl)
|
||||
if not loaded:
|
||||
logger.info("👣 首次运行,定义软零点并保存。")
|
||||
ctrl.define_current_as_zero("work_origin.json")
|
||||
|
||||
# === 软零点回归动作 ===
|
||||
ctrl.return_to_work_origin()
|
||||
|
||||
# === 可选软零点运动测试 ===
|
||||
# test_soft_zero_move(ctrl)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("🛑 手动中断退出")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ 调试出错: {e}")
|
||||
|
||||
finally:
|
||||
if hasattr(ctrl.client, "transport"):
|
||||
ctrl.client.transport.close()
|
||||
logger.info("串口已安全关闭 ✅")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
58
unilabos/devices/laiyu_liquid_test/driver_status_test.py
Normal file
58
unilabos/devices/laiyu_liquid_test/driver_status_test.py
Normal file
@@ -0,0 +1,58 @@
|
||||
|
||||
import logging
|
||||
from xyz_stepper_driver import (
|
||||
ModbusRTUTransport,
|
||||
ModbusClient,
|
||||
XYZStepperController,
|
||||
MotorAxis,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("XYZStepperCommTest")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
|
||||
def test_xyz_stepper_comm():
|
||||
"""仅测试 Modbus 通信是否正常(并输出寄存器数据,不做电机运动)"""
|
||||
port = "/dev/ttyUSB1"
|
||||
baudrate = 115200
|
||||
timeout = 1.2 # 略长避免响应被截断
|
||||
|
||||
logger.info(f"尝试连接 Modbus 设备 {port} ...")
|
||||
transport = ModbusRTUTransport(port, baudrate=baudrate, timeout=timeout)
|
||||
transport.open()
|
||||
|
||||
client = ModbusClient(transport)
|
||||
ctrl = XYZStepperController(client)
|
||||
|
||||
try:
|
||||
logger.info("✅ 串口已打开,开始读取三个轴状态(打印寄存器内容) ...")
|
||||
for axis in [MotorAxis.X, MotorAxis.Y, MotorAxis.Z]:
|
||||
addr = ctrl.axis_addr[axis]
|
||||
|
||||
try:
|
||||
# # 在 get_status 前打印原始寄存器内容
|
||||
# regs = client.read_registers(addr, ctrl.REG_STATUS, 6)
|
||||
# hex_regs = [f"0x{val:04X}" for val in regs]
|
||||
# logger.info(f"[{axis.name}] 原始寄存器 ({len(regs)} 个): {regs} -> {hex_regs}")
|
||||
|
||||
# 调用 get_status() 正常解析
|
||||
status = ctrl.get_status(axis)
|
||||
logger.info(
|
||||
f"[{axis.name}] ✅ 通信正常: steps={status.steps}, speed={status.speed}, "
|
||||
f"current={status.current}, status={status.status.name}"
|
||||
)
|
||||
|
||||
except Exception as e_axis:
|
||||
logger.error(f"[{axis.name}] ❌ 通信失败: {e_axis}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 通讯测试失败: {e}")
|
||||
|
||||
finally:
|
||||
transport.close()
|
||||
logger.info("🔌 串口已关闭")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_xyz_stepper_comm()
|
||||
8
unilabos/devices/laiyu_liquid_test/work_origin.json
Normal file
8
unilabos/devices/laiyu_liquid_test/work_origin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"work_origin_steps": {
|
||||
"x": 11799,
|
||||
"y": 11476,
|
||||
"z": 3312
|
||||
},
|
||||
"timestamp": "2025-11-04T15:31:09.802155"
|
||||
}
|
||||
336
unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py
Normal file
336
unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py
Normal file
@@ -0,0 +1,336 @@
|
||||
|
||||
"""
|
||||
XYZ 三轴步进电机驱动(统一字符串参数版)
|
||||
基于 Modbus RTU 协议
|
||||
Author: Xiuyu Chen (Modified by Assistant)
|
||||
"""
|
||||
|
||||
import serial # type: ignore
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
# ========== 日志配置 ==========
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("XYZStepper")
|
||||
|
||||
|
||||
# ========== 层 1:Modbus RTU ==========
|
||||
class ModbusException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ModbusRTUTransport:
|
||||
"""底层串口通信层"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.2):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.ser: Optional[serial.Serial] = None
|
||||
|
||||
def open(self):
|
||||
try:
|
||||
self.ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=0.02,
|
||||
write_timeout=0.5,
|
||||
)
|
||||
logger.info(f"[RTU] 串口连接成功: {self.port}")
|
||||
except Exception as e:
|
||||
raise ModbusException(f"无法打开串口 {self.port}: {e}")
|
||||
|
||||
def close(self):
|
||||
if self.ser and self.ser.is_open:
|
||||
self.ser.close()
|
||||
logger.info("[RTU] 串口已关闭")
|
||||
|
||||
def send(self, frame: bytes):
|
||||
if not self.ser or not self.ser.is_open:
|
||||
raise ModbusException("串口未连接")
|
||||
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write(frame)
|
||||
self.ser.flush()
|
||||
logger.debug(f"[TX] {frame.hex(' ').upper()}")
|
||||
|
||||
def receive(self, expected_len: int) -> bytes:
|
||||
if not self.ser or not self.ser.is_open:
|
||||
raise ModbusException("串口未连接")
|
||||
|
||||
start = time.time()
|
||||
buf = bytearray()
|
||||
while len(buf) < expected_len and (time.time() - start) < self.timeout:
|
||||
chunk = self.ser.read(expected_len - len(buf))
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
# ========== 层 2:Modbus 协议 ==========
|
||||
class ModbusFunction(Enum):
|
||||
READ_HOLDING_REGISTERS = 0x03
|
||||
WRITE_SINGLE_REGISTER = 0x06
|
||||
WRITE_MULTIPLE_REGISTERS = 0x10
|
||||
|
||||
|
||||
class ModbusClient:
|
||||
"""Modbus RTU 客户端"""
|
||||
|
||||
def __init__(self, transport: ModbusRTUTransport):
|
||||
self.transport = transport
|
||||
|
||||
@staticmethod
|
||||
def calc_crc(data: bytes) -> bytes:
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return struct.pack("<H", crc)
|
||||
|
||||
def send_request(self, addr: int, func: int, payload: bytes) -> bytes:
|
||||
frame = bytes([addr, func]) + payload
|
||||
full = frame + self.calc_crc(frame)
|
||||
self.transport.send(full)
|
||||
time.sleep(0.01)
|
||||
resp = self.transport.ser.read(256)
|
||||
if not resp:
|
||||
raise ModbusException("未收到响应")
|
||||
|
||||
start = resp.find(bytes([addr, func]))
|
||||
if start > 0:
|
||||
resp = resp[start:]
|
||||
if len(resp) < 5:
|
||||
raise ModbusException(f"响应长度不足: {resp.hex(' ').upper()}")
|
||||
if self.calc_crc(resp[:-2]) != resp[-2:]:
|
||||
raise ModbusException("CRC 校验失败")
|
||||
return resp
|
||||
|
||||
def read_registers(self, addr: int, start: int, count: int) -> List[int]:
|
||||
payload = struct.pack(">HH", start, count)
|
||||
resp = self.send_request(addr, ModbusFunction.READ_HOLDING_REGISTERS.value, payload)
|
||||
byte_count = resp[2]
|
||||
regs = [struct.unpack(">H", resp[3 + i:5 + i])[0] for i in range(0, byte_count, 2)]
|
||||
return regs
|
||||
|
||||
def write_single_register(self, addr: int, reg: int, val: int) -> bool:
|
||||
payload = struct.pack(">HH", reg, val)
|
||||
resp = self.send_request(addr, ModbusFunction.WRITE_SINGLE_REGISTER.value, payload)
|
||||
return resp[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value
|
||||
|
||||
def write_multiple_registers(self, addr: int, start: int, values: List[int]) -> bool:
|
||||
byte_count = len(values) * 2
|
||||
payload = struct.pack(">HHB", start, len(values), byte_count)
|
||||
payload += b"".join(struct.pack(">H", v & 0xFFFF) for v in values)
|
||||
resp = self.send_request(addr, ModbusFunction.WRITE_MULTIPLE_REGISTERS.value, payload)
|
||||
return resp[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value
|
||||
|
||||
|
||||
# ========== 层 3:业务逻辑 ==========
|
||||
class MotorAxis(Enum):
|
||||
X = 1
|
||||
Y = 2
|
||||
Z = 3
|
||||
|
||||
|
||||
class MotorStatus(Enum):
|
||||
STANDBY = 0
|
||||
RUNNING = 1
|
||||
COLLISION_STOP = 2
|
||||
FORWARD_LIMIT_STOP = 3
|
||||
REVERSE_LIMIT_STOP = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotorPosition:
|
||||
steps: int
|
||||
speed: int
|
||||
current: int
|
||||
status: MotorStatus
|
||||
|
||||
|
||||
class XYZStepperController:
|
||||
"""XYZ 三轴步进控制器(字符串接口版)"""
|
||||
|
||||
STEPS_PER_REV = 16384
|
||||
LEAD_MM_X, LEAD_MM_Y, LEAD_MM_Z = 80.0, 80.0, 5.0
|
||||
STEPS_PER_MM_X = STEPS_PER_REV / LEAD_MM_X
|
||||
STEPS_PER_MM_Y = STEPS_PER_REV / LEAD_MM_Y
|
||||
STEPS_PER_MM_Z = STEPS_PER_REV / LEAD_MM_Z
|
||||
|
||||
REG_STATUS, REG_POS_HIGH, REG_POS_LOW = 0x00, 0x01, 0x02
|
||||
REG_ACTUAL_SPEED, REG_CURRENT, REG_ENABLE = 0x03, 0x05, 0x06
|
||||
REG_ZERO_CMD, REG_TARGET_HIGH, REG_TARGET_LOW = 0x0F, 0x10, 0x11
|
||||
REG_SPEED, REG_ACCEL, REG_PRECISION, REG_START = 0x13, 0x14, 0x15, 0x16
|
||||
REG_COMMAND = 0x60
|
||||
|
||||
def __init__(self, client: Optional[ModbusClient] = None,
|
||||
port="/dev/ttyUSB0", baudrate=115200,
|
||||
origin_path="unilabos/devices/laiyu_liquid_test/work_origin.json"):
|
||||
if client is None:
|
||||
transport = ModbusRTUTransport(port, baudrate)
|
||||
transport.open()
|
||||
self.client = ModbusClient(transport)
|
||||
else:
|
||||
self.client = client
|
||||
|
||||
self.axis_addr = {MotorAxis.X: 1, MotorAxis.Y: 2, MotorAxis.Z: 3}
|
||||
self.work_origin_steps = {"x": 0, "y": 0, "z": 0}
|
||||
self.is_homed = False
|
||||
self._load_work_origin(origin_path)
|
||||
|
||||
# ========== 基础工具 ==========
|
||||
@staticmethod
|
||||
def s16(v: int) -> int:
|
||||
return v - 0x10000 if v & 0x8000 else v
|
||||
|
||||
@staticmethod
|
||||
def s32(h: int, l: int) -> int:
|
||||
v = (h << 16) | l
|
||||
return v - 0x100000000 if v & 0x80000000 else v
|
||||
|
||||
@classmethod
|
||||
def mm_to_steps(cls, axis: str, mm: float = 0.0) -> int:
|
||||
axis = axis.upper()
|
||||
if axis == "X":
|
||||
return int(mm * cls.STEPS_PER_MM_X)
|
||||
elif axis == "Y":
|
||||
return int(mm * cls.STEPS_PER_MM_Y)
|
||||
elif axis == "Z":
|
||||
return int(mm * cls.STEPS_PER_MM_Z)
|
||||
raise ValueError(f"未知轴: {axis}")
|
||||
|
||||
@classmethod
|
||||
def steps_to_mm(cls, axis: str, steps: int) -> float:
|
||||
axis = axis.upper()
|
||||
if axis == "X":
|
||||
return steps / cls.STEPS_PER_MM_X
|
||||
elif axis == "Y":
|
||||
return steps / cls.STEPS_PER_MM_Y
|
||||
elif axis == "Z":
|
||||
return steps / cls.STEPS_PER_MM_Z
|
||||
raise ValueError(f"未知轴: {axis}")
|
||||
|
||||
# ========== 状态与控制 ==========
|
||||
def get_status(self, axis: str = "Z") -> list:
|
||||
"""返回简化数组格式: [steps, speed, current, status_value]"""
|
||||
if isinstance(axis, MotorAxis):
|
||||
axis_enum = axis
|
||||
elif isinstance(axis, str):
|
||||
axis_enum = MotorAxis[axis.upper()]
|
||||
else:
|
||||
raise TypeError("axis 参数必须为 str 或 MotorAxis")
|
||||
|
||||
vals = self.client.read_registers(self.axis_addr[axis_enum], self.REG_STATUS, 6)
|
||||
return [
|
||||
self.s32(vals[1], vals[2]),
|
||||
self.s16(vals[3]),
|
||||
vals[4],
|
||||
int(MotorStatus(vals[0]).value)
|
||||
]
|
||||
|
||||
def enable(self, axis: str, state: bool) -> bool:
|
||||
a = MotorAxis[axis.upper()]
|
||||
return self.client.write_single_register(self.axis_addr[a], self.REG_ENABLE, 1 if state else 0)
|
||||
|
||||
def wait_complete(self, axis: str, timeout=30.0) -> bool:
|
||||
a = axis.upper()
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
vals = self.get_status(a)
|
||||
st = MotorStatus(vals[3]) # 第4个元素是状态值
|
||||
if st == MotorStatus.STANDBY:
|
||||
return True
|
||||
if st in (MotorStatus.COLLISION_STOP, MotorStatus.FORWARD_LIMIT_STOP, MotorStatus.REVERSE_LIMIT_STOP):
|
||||
logger.warning(f"{a} 轴异常停止: {st.name}")
|
||||
return False
|
||||
time.sleep(0.1)
|
||||
logger.warning(f"{a} 轴运动超时")
|
||||
return False
|
||||
|
||||
# ========== 控制命令 ==========
|
||||
def move_to(self, axis: str, steps: int, speed: int = 2000, acc: int = 500, precision: int = 50) -> bool:
|
||||
a = MotorAxis[axis.upper()]
|
||||
addr = self.axis_addr[a]
|
||||
hi, lo = (steps >> 16) & 0xFFFF, steps & 0xFFFF
|
||||
values = [hi, lo, speed, acc, precision]
|
||||
ok = self.client.write_multiple_registers(addr, self.REG_TARGET_HIGH, values)
|
||||
if ok:
|
||||
self.client.write_single_register(addr, self.REG_START, 1)
|
||||
return ok
|
||||
|
||||
def move_xyz_work(self, x: float = 0.0, y: float = 0.0, z: float = 0.0, speed: int = 100, acc: int = 1500):
|
||||
logger.info("🧭 执行安全多轴运动:Z→XY→Z")
|
||||
if z is not None:
|
||||
safe_z = self._to_machine_steps("Z", 0.0)
|
||||
self.move_to("Z", safe_z, speed, acc)
|
||||
self.wait_complete("Z")
|
||||
|
||||
if x is not None or y is not None:
|
||||
if x is not None:
|
||||
self.move_to("X", self._to_machine_steps("X", x), speed, acc)
|
||||
if y is not None:
|
||||
self.move_to("Y", self._to_machine_steps("Y", y), speed, acc)
|
||||
if x is not None:
|
||||
self.wait_complete("X")
|
||||
if y is not None:
|
||||
self.wait_complete("Y")
|
||||
|
||||
if z is not None:
|
||||
self.move_to("Z", self._to_machine_steps("Z", z), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
logger.info("✅ 多轴顺序运动完成")
|
||||
|
||||
# ========== 坐标与零点 ==========
|
||||
def _to_machine_steps(self, axis: str, mm: float) -> int:
|
||||
base = self.work_origin_steps.get(axis.lower(), 0)
|
||||
return base + self.mm_to_steps(axis, mm)
|
||||
|
||||
def define_current_as_zero(self, save_path="work_origin.json"):
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
origin = {}
|
||||
for axis in ["X", "Y", "Z"]:
|
||||
vals = self.get_status(axis)
|
||||
origin[axis.lower()] = int(vals[0]) # 第1个是步数
|
||||
with open(save_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"work_origin_steps": origin, "timestamp": datetime.now().isoformat()}, f, indent=2)
|
||||
self.work_origin_steps = origin
|
||||
self.is_homed = True
|
||||
logger.info(f"✅ 零点已定义并保存至 {save_path}")
|
||||
|
||||
def _load_work_origin(self, path: str) -> bool:
|
||||
import json, os
|
||||
|
||||
if not os.path.exists(path):
|
||||
logger.warning("⚠️ 未找到软零点文件")
|
||||
return False
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.work_origin_steps = data.get("work_origin_steps", {"x": 0, "y": 0, "z": 0})
|
||||
self.is_homed = True
|
||||
logger.info(f"📂 软零点已加载: {self.work_origin_steps}")
|
||||
return True
|
||||
|
||||
def return_to_work_origin(self, speed: int = 200, acc: int = 800):
|
||||
logger.info("🏁 回工件软零点")
|
||||
self.move_to("Z", self._to_machine_steps("Z", 0.0), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
self.move_to("X", self.work_origin_steps.get("x", 0), speed, acc)
|
||||
self.move_to("Y", self.work_origin_steps.get("y", 0), speed, acc)
|
||||
self.wait_complete("X")
|
||||
self.wait_complete("Y")
|
||||
self.move_to("Z", self.work_origin_steps.get("z", 0), speed, acc)
|
||||
self.wait_complete("Z")
|
||||
logger.info("🎯 回软零点完成 ✅")
|
||||
0
unilabos/devices/liquid_handling/laiyu/__init__.py
Normal file
0
unilabos/devices/liquid_handling/laiyu/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端模块
|
||||
|
||||
提供设备后端接口和实现
|
||||
"""
|
||||
|
||||
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||
|
||||
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||
334
unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py
Normal file
334
unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
LaiYu液体处理设备后端实现
|
||||
|
||||
提供设备的后端接口和控制逻辑
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# 尝试导入PyLabRobot后端
|
||||
try:
|
||||
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
# 创建模拟后端基类
|
||||
class LiquidHandlerBackend:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.is_connected = False
|
||||
|
||||
def connect(self):
|
||||
"""连接设备"""
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
pass
|
||||
|
||||
|
||||
class LaiYuLiquidBackend(LiquidHandlerBackend):
|
||||
"""LaiYu液体处理设备后端"""
|
||||
|
||||
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
|
||||
"""
|
||||
初始化LaiYu液体处理设备后端
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
"""
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
|
||||
super().__init__()
|
||||
else:
|
||||
# 模拟版本接受 name 参数
|
||||
super().__init__(name)
|
||||
|
||||
self.name = name
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.is_connected = False
|
||||
self.device_info = {
|
||||
"name": "LaiYu液体处理设备",
|
||||
"version": "1.0.0",
|
||||
"manufacturer": "LaiYu",
|
||||
"model": "LaiYu_Liquid_Handler"
|
||||
}
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到LaiYu液体处理设备
|
||||
|
||||
Returns:
|
||||
bool: 连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在连接到LaiYu液体处理设备...")
|
||||
# 这里应该实现实际的设备连接逻辑
|
||||
# 目前返回模拟连接成功
|
||||
self.is_connected = True
|
||||
self.logger.info("成功连接到LaiYu液体处理设备")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
"""
|
||||
断开与LaiYu液体处理设备的连接
|
||||
|
||||
Returns:
|
||||
bool: 断开连接是否成功
|
||||
"""
|
||||
try:
|
||||
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
|
||||
# 这里应该实现实际的设备断开连接逻辑
|
||||
self.is_connected = False
|
||||
self.logger.info("成功断开与LaiYu液体处理设备的连接")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
|
||||
return False
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
"""
|
||||
检查设备是否已连接
|
||||
|
||||
Returns:
|
||||
bool: 设备是否已连接
|
||||
"""
|
||||
return self.is_connected
|
||||
|
||||
def get_device_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备信息字典
|
||||
"""
|
||||
return self.device_info.copy()
|
||||
|
||||
def home_device(self) -> bool:
|
||||
"""
|
||||
设备归零操作
|
||||
|
||||
Returns:
|
||||
bool: 归零是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行归零操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info("正在执行设备归零操作...")
|
||||
# 这里应该实现实际的设备归零逻辑
|
||||
self.logger.info("设备归零操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"设备归零操作失败: {e}")
|
||||
return False
|
||||
|
||||
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
吸液操作
|
||||
|
||||
Args:
|
||||
volume: 吸液体积 (微升)
|
||||
location: 吸液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 吸液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行吸液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的吸液逻辑
|
||||
self.logger.info("吸液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"吸液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
排液操作
|
||||
|
||||
Args:
|
||||
volume: 排液体积 (微升)
|
||||
location: 排液位置信息
|
||||
|
||||
Returns:
|
||||
bool: 排液是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行排液操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
|
||||
# 这里应该实现实际的排液逻辑
|
||||
self.logger.info("排液操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"排液操作失败: {e}")
|
||||
return False
|
||||
|
||||
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
取枪头操作
|
||||
|
||||
Args:
|
||||
location: 枪头位置信息
|
||||
|
||||
Returns:
|
||||
bool: 取枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行取枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行取枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的取枪头逻辑
|
||||
self.logger.info("取枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"取枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def drop_tip(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
丢弃枪头操作
|
||||
|
||||
Args:
|
||||
location: 丢弃位置信息
|
||||
|
||||
Returns:
|
||||
bool: 丢弃枪头是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行丢弃枪头操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
|
||||
# 这里应该实现实际的丢弃枪头逻辑
|
||||
self.logger.info("丢弃枪头操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"丢弃枪头操作失败: {e}")
|
||||
return False
|
||||
|
||||
def move_to(self, location: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
移动到指定位置
|
||||
|
||||
Args:
|
||||
location: 目标位置信息
|
||||
|
||||
Returns:
|
||||
bool: 移动是否成功
|
||||
"""
|
||||
if not self.is_connected:
|
||||
self.logger.error("设备未连接,无法执行移动操作")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.logger.info(f"正在移动到位置: {location}")
|
||||
# 这里应该实现实际的移动逻辑
|
||||
self.logger.info("移动操作完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"移动操作失败: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备状态
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 设备状态信息
|
||||
"""
|
||||
return {
|
||||
"connected": self.is_connected,
|
||||
"device_info": self.device_info,
|
||||
"status": "ready" if self.is_connected else "disconnected"
|
||||
}
|
||||
|
||||
# PyLabRobot 抽象方法实现
|
||||
def stop(self):
|
||||
"""停止所有操作"""
|
||||
self.logger.info("停止所有操作")
|
||||
pass
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
"""返回通道数量"""
|
||||
return 1 # 单通道移液器
|
||||
|
||||
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
|
||||
"""检查是否可以拾取吸头"""
|
||||
return True # 简化实现,总是返回True
|
||||
|
||||
def pick_up_tips(self, tip_rack, tip_positions):
|
||||
"""拾取多个吸头"""
|
||||
self.logger.info(f"拾取吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def drop_tips(self, tip_rack, tip_positions):
|
||||
"""丢弃多个吸头"""
|
||||
self.logger.info(f"丢弃吸头: {tip_positions}")
|
||||
pass
|
||||
|
||||
def pick_up_tips96(self, tip_rack):
|
||||
"""拾取96个吸头"""
|
||||
self.logger.info("拾取96个吸头")
|
||||
pass
|
||||
|
||||
def drop_tips96(self, tip_rack):
|
||||
"""丢弃96个吸头"""
|
||||
self.logger.info("丢弃96个吸头")
|
||||
pass
|
||||
|
||||
def aspirate96(self, volume, plate, well_positions):
|
||||
"""96通道吸液"""
|
||||
self.logger.info(f"96通道吸液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def dispense96(self, volume, plate, well_positions):
|
||||
"""96通道排液"""
|
||||
self.logger.info(f"96通道排液: 体积={volume}")
|
||||
pass
|
||||
|
||||
def pick_up_resource(self, resource, location):
|
||||
"""拾取资源"""
|
||||
self.logger.info(f"拾取资源: {resource}")
|
||||
pass
|
||||
|
||||
def drop_resource(self, resource, location):
|
||||
"""放置资源"""
|
||||
self.logger.info(f"放置资源: {resource}")
|
||||
pass
|
||||
|
||||
def move_picked_up_resource(self, resource, location):
|
||||
"""移动已拾取的资源"""
|
||||
self.logger.info(f"移动资源: {resource} 到 {location}")
|
||||
pass
|
||||
|
||||
|
||||
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
|
||||
"""
|
||||
创建LaiYu液体处理设备后端实例
|
||||
|
||||
Args:
|
||||
name: 后端名称
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidBackend: 后端实例
|
||||
"""
|
||||
return LaiYuLiquidBackend(name)
|
||||
@@ -0,0 +1,385 @@
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pylabrobot.liquid_handling.backends.backend import (
|
||||
LiquidHandlerBackend,
|
||||
)
|
||||
from pylabrobot.liquid_handling.standard import (
|
||||
Drop,
|
||||
DropTipRack,
|
||||
MultiHeadAspirationContainer,
|
||||
MultiHeadAspirationPlate,
|
||||
MultiHeadDispenseContainer,
|
||||
MultiHeadDispensePlate,
|
||||
Pickup,
|
||||
PickupTipRack,
|
||||
ResourceDrop,
|
||||
ResourceMove,
|
||||
ResourcePickup,
|
||||
SingleChannelAspiration,
|
||||
SingleChannelDispense,
|
||||
)
|
||||
from pylabrobot.resources import Resource, Tip
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import JointState
|
||||
import time
|
||||
from rclpy.action import ActionClient
|
||||
from unilabos_msgs.action import SendCmd
|
||||
import re
|
||||
|
||||
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
|
||||
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
|
||||
|
||||
|
||||
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
|
||||
"""Chatter box backend for device-free testing. Prints out all operations."""
|
||||
|
||||
_pip_length = 5
|
||||
_vol_length = 8
|
||||
_resource_length = 20
|
||||
_offset_length = 16
|
||||
_flow_rate_length = 10
|
||||
_blowout_length = 10
|
||||
_lld_z_length = 10
|
||||
_kwargs_length = 15
|
||||
_tip_type_length = 12
|
||||
_max_volume_length = 16
|
||||
_fitting_depth_length = 20
|
||||
_tip_length_length = 16
|
||||
# _pickup_method_length = 20
|
||||
_filter_length = 10
|
||||
|
||||
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
|
||||
"""Initialize a chatter box backend."""
|
||||
super().__init__()
|
||||
self._num_channels = num_channels
|
||||
self.tip_length = tip_length
|
||||
self.total_height = total_height
|
||||
# rclpy.init()
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
self.hardware_interface = PipetteController(port=port)
|
||||
|
||||
async def setup(self):
|
||||
# self.joint_state_publisher = JointStatePublisher()
|
||||
# self.hardware_interface.xyz_controller.connect_device()
|
||||
# self.hardware_interface.xyz_controller.home_all_axes()
|
||||
await super().setup()
|
||||
self.hardware_interface.connect()
|
||||
self.hardware_interface.initialize()
|
||||
|
||||
print("Setting up the liquid handler.")
|
||||
|
||||
async def stop(self):
|
||||
print("Stopping the liquid handler.")
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {**super().serialize(), "num_channels": self.num_channels}
|
||||
|
||||
def pipette_aspirate(self, volume: float, flow_rate: float):
|
||||
|
||||
self.hardware_interface.pipette.set_max_speed(flow_rate)
|
||||
res = self.hardware_interface.pipette.aspirate(volume=volume)
|
||||
|
||||
if not res:
|
||||
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
|
||||
return
|
||||
|
||||
self.hardware_interface.current_volume += volume
|
||||
|
||||
def pipette_dispense(self, volume: float, flow_rate: float):
|
||||
|
||||
self.hardware_interface.pipette.set_max_speed(flow_rate)
|
||||
res = self.hardware_interface.pipette.dispense(volume=volume)
|
||||
if not res:
|
||||
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
|
||||
return
|
||||
self.hardware_interface.current_volume -= volume
|
||||
|
||||
@property
|
||||
def num_channels(self) -> int:
|
||||
return self._num_channels
|
||||
|
||||
async def assigned_resource_callback(self, resource: Resource):
|
||||
print(f"Resource {resource.name} was assigned to the liquid handler.")
|
||||
|
||||
async def unassigned_resource_callback(self, name: str):
|
||||
print(f"Resource {name} was unassigned from the liquid handler.")
|
||||
|
||||
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||
print("Picking up tips:")
|
||||
# print(ops.tip)
|
||||
header = (
|
||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
||||
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
||||
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
||||
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
||||
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
||||
)
|
||||
# print(header)
|
||||
|
||||
for op, channel in zip(ops, use_channels):
|
||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{channel}: "
|
||||
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
||||
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
||||
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
||||
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
||||
)
|
||||
# print(row)
|
||||
# print(op.resource.get_absolute_location())
|
||||
|
||||
self.tip_length = ops[0].tip.total_tip_length
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
offset_xyz = ops[0].offset
|
||||
x = coordinate.x + offset_xyz.x
|
||||
y = coordinate.y + offset_xyz.y
|
||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||
# print("moving")
|
||||
self.hardware_interface._update_tip_status()
|
||||
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
||||
print("已有枪头,无需重复拾取")
|
||||
return
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=100)
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
|
||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
|
||||
# goback()
|
||||
|
||||
|
||||
|
||||
|
||||
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||
print("Dropping tips:")
|
||||
header = (
|
||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
||||
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
||||
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
||||
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
||||
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
||||
)
|
||||
# print(header)
|
||||
|
||||
for op, channel in zip(ops, use_channels):
|
||||
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{channel}: "
|
||||
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
|
||||
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
|
||||
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
|
||||
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
|
||||
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
|
||||
)
|
||||
# print(row)
|
||||
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
offset_xyz = ops[0].offset
|
||||
x = coordinate.x + offset_xyz.x
|
||||
y = coordinate.y + offset_xyz.y
|
||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
|
||||
# print(x, y, z)
|
||||
# print("moving")
|
||||
self.hardware_interface._update_tip_status()
|
||||
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
|
||||
print("无枪头,无需丢弃")
|
||||
return
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.hardware_interface.eject_tip
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
ops: List[SingleChannelAspiration],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
print("Aspirating:")
|
||||
header = (
|
||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
||||
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
||||
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
||||
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
||||
# f"{'liquids':<20}" # TODO: add liquids
|
||||
)
|
||||
for key in backend_kwargs:
|
||||
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
|
||||
# print(header)
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<15}"
|
||||
# print(row)
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
offset_xyz = ops[0].offset
|
||||
x = coordinate.x + offset_xyz.x
|
||||
y = coordinate.y + offset_xyz.y
|
||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||
# print(x, y, z)
|
||||
# print("moving")
|
||||
|
||||
# 判断枪头是否存在
|
||||
self.hardware_interface._update_tip_status()
|
||||
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
||||
print("无枪头,无法吸液")
|
||||
return
|
||||
# 判断吸液量是否超过枪头容量
|
||||
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
|
||||
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
|
||||
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
|
||||
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
|
||||
return
|
||||
|
||||
# 移动到吸液位置
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
|
||||
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
||||
if blow_out_air_volume >0:
|
||||
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||
|
||||
|
||||
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
ops: List[SingleChannelDispense],
|
||||
use_channels: List[int],
|
||||
**backend_kwargs,
|
||||
):
|
||||
# print("Dispensing:")
|
||||
header = (
|
||||
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
|
||||
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
||||
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
||||
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
||||
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
||||
# f"{'liquids':<20}" # TODO: add liquids
|
||||
)
|
||||
for key in backend_kwargs:
|
||||
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
|
||||
# print(header)
|
||||
|
||||
for o, p in zip(ops, use_channels):
|
||||
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||
row = (
|
||||
f" p{p}: "
|
||||
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
|
||||
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
|
||||
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
|
||||
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
|
||||
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
|
||||
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
|
||||
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||
)
|
||||
for key, value in backend_kwargs.items():
|
||||
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||
value = "".join("T" if v else "F" for v in value)
|
||||
if isinstance(value, list):
|
||||
value = "".join(map(str, value))
|
||||
row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
|
||||
# print(row)
|
||||
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||
offset_xyz = ops[0].offset
|
||||
x = coordinate.x + offset_xyz.x
|
||||
y = coordinate.y + offset_xyz.y
|
||||
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
|
||||
# print(x, y, z)
|
||||
# print("moving")
|
||||
|
||||
# 判断枪头是否存在
|
||||
self.hardware_interface._update_tip_status()
|
||||
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
|
||||
print("无枪头,无法排液")
|
||||
return
|
||||
# 判断排液量是否超过枪头容量
|
||||
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
|
||||
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
|
||||
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
|
||||
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
|
||||
return
|
||||
|
||||
|
||||
# 移动到排液位置
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z)
|
||||
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
|
||||
|
||||
|
||||
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
|
||||
if blow_out_air_volume > 0:
|
||||
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
|
||||
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
|
||||
|
||||
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||
print(f"Picking up tips from {pickup.resource.name}.")
|
||||
|
||||
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||
print(f"Dropping tips to {drop.resource.name}.")
|
||||
|
||||
async def aspirate96(
|
||||
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||
):
|
||||
if isinstance(aspiration, MultiHeadAspirationPlate):
|
||||
resource = aspiration.wells[0].parent
|
||||
else:
|
||||
resource = aspiration.container
|
||||
print(f"Aspirating {aspiration.volume} from {resource}.")
|
||||
|
||||
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||
if isinstance(dispense, MultiHeadDispensePlate):
|
||||
resource = dispense.wells[0].parent
|
||||
else:
|
||||
resource = dispense.container
|
||||
print(f"Dispensing {dispense.volume} to {resource}.")
|
||||
|
||||
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||
print(f"Picking up resource: {pickup}")
|
||||
|
||||
async def move_picked_up_resource(self, move: ResourceMove):
|
||||
print(f"Moving picked up resource: {move}")
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop):
|
||||
print(f"Dropping resource: {drop}")
|
||||
|
||||
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||
return True
|
||||
|
||||
2620
unilabos/devices/liquid_handling/laiyu/config/deckconfig.json
Normal file
2620
unilabos/devices/liquid_handling/laiyu/config/deckconfig.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
LaiYu_Liquid 控制器模块
|
||||
|
||||
该模块包含了LaiYu_Liquid液体处理工作站的高级控制器:
|
||||
- 移液器控制器:提供液体处理的高级接口
|
||||
- XYZ运动控制器:提供三轴运动的高级接口
|
||||
"""
|
||||
|
||||
# 移液器控制器导入
|
||||
from .pipette_controller import PipetteController
|
||||
|
||||
# XYZ运动控制器导入
|
||||
from .xyz_controller import XYZController
|
||||
|
||||
__all__ = [
|
||||
# 移液器控制器
|
||||
"PipetteController",
|
||||
|
||||
# XYZ运动控制器
|
||||
"XYZController",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Controller Team"
|
||||
__description__ = "LaiYu_Liquid 高级控制器集合"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"machine_origin_steps": {
|
||||
"x": -198.43,
|
||||
"y": -94.25,
|
||||
"z": -0.73
|
||||
},
|
||||
"work_origin_steps": {
|
||||
"x": 59.39,
|
||||
"y": 216.99,
|
||||
"z": 2.0
|
||||
},
|
||||
"is_homed": true,
|
||||
"timestamp": "2025-10-29T20:34:11.749055"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1253
unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py
Normal file
1253
unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
881
unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py
Normal file
881
unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py
Normal file
@@ -0,0 +1,881 @@
|
||||
"""
|
||||
LaiYu_Liquid 液体处理工作站主要集成文件
|
||||
|
||||
该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。
|
||||
主要包含:
|
||||
- LaiYuLiquidBackend: 硬件通信后端
|
||||
- LaiYuLiquid: 主要接口类
|
||||
- 相关的异常类和容器类
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional, Dict, Any, Union, Tuple
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# 基础导入
|
||||
try:
|
||||
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
# 如果 pylabrobot 不可用,创建基础的模拟类
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
|
||||
class Resource:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
class Deck(Resource):
|
||||
pass
|
||||
|
||||
class Plate(Resource):
|
||||
pass
|
||||
|
||||
class TipRack(Resource):
|
||||
pass
|
||||
|
||||
class Tip(Resource):
|
||||
pass
|
||||
|
||||
class Well(Resource):
|
||||
pass
|
||||
|
||||
# LaiYu_Liquid 控制器导入
|
||||
try:
|
||||
from .controllers.pipette_controller import (
|
||||
PipetteController, TipStatus, LiquidClass, LiquidParameters
|
||||
)
|
||||
from .controllers.xyz_controller import (
|
||||
XYZController, MachineConfig, CoordinateOrigin, MotorAxis
|
||||
)
|
||||
CONTROLLERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
CONTROLLERS_AVAILABLE = False
|
||||
# 创建模拟的控制器类
|
||||
class PipetteController:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
return True
|
||||
|
||||
def initialize(self):
|
||||
return True
|
||||
|
||||
class XYZController:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def connect_device(self):
|
||||
return True
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaiYuLiquidError(RuntimeError):
|
||||
"""LaiYu_Liquid 设备异常"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaiYuLiquidConfig:
|
||||
"""LaiYu_Liquid 设备配置"""
|
||||
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
|
||||
address: int = 1 # 设备地址
|
||||
baudrate: int = 9600 # 波特率
|
||||
timeout: float = 5.0 # 通信超时时间
|
||||
|
||||
# 工作台尺寸
|
||||
deck_width: float = 340.0 # 工作台宽度 (mm)
|
||||
deck_height: float = 250.0 # 工作台高度 (mm)
|
||||
deck_depth: float = 160.0 # 工作台深度 (mm)
|
||||
|
||||
# 移液参数
|
||||
max_volume: float = 1000.0 # 最大体积 (μL)
|
||||
min_volume: float = 0.1 # 最小体积 (μL)
|
||||
|
||||
# 运动参数
|
||||
max_speed: float = 100.0 # 最大速度 (mm/s)
|
||||
acceleration: float = 50.0 # 加速度 (mm/s²)
|
||||
|
||||
# 安全参数
|
||||
safe_height: float = 50.0 # 安全高度 (mm)
|
||||
tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm)
|
||||
liquid_detection: bool = True # 液面检测
|
||||
|
||||
# 取枪头相关参数
|
||||
tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm)
|
||||
tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s)
|
||||
tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm)
|
||||
tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm)
|
||||
tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm)
|
||||
|
||||
# 丢弃枪头相关参数
|
||||
tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm)
|
||||
tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm)
|
||||
trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm)
|
||||
|
||||
# 安全范围配置
|
||||
deck_width: float = 300.0 # 工作台宽度 (mm)
|
||||
deck_height: float = 200.0 # 工作台高度 (mm)
|
||||
deck_depth: float = 100.0 # 工作台深度 (mm)
|
||||
safe_height: float = 50.0 # 安全高度 (mm)
|
||||
position_validation: bool = True # 启用位置验证
|
||||
emergency_stop_enabled: bool = True # 启用紧急停止
|
||||
|
||||
|
||||
class LaiYuLiquidDeck:
|
||||
"""LaiYu_Liquid 工作台管理"""
|
||||
|
||||
def __init__(self, config: LaiYuLiquidConfig):
|
||||
self.config = config
|
||||
self.resources: Dict[str, Resource] = {}
|
||||
self.positions: Dict[str, Tuple[float, float, float]] = {}
|
||||
|
||||
def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]):
|
||||
"""添加资源到工作台"""
|
||||
self.resources[name] = resource
|
||||
self.positions[name] = position
|
||||
|
||||
def get_resource(self, name: str) -> Optional[Resource]:
|
||||
"""获取资源"""
|
||||
return self.resources.get(name)
|
||||
|
||||
def get_position(self, name: str) -> Optional[Tuple[float, float, float]]:
|
||||
"""获取资源位置"""
|
||||
return self.positions.get(name)
|
||||
|
||||
def list_resources(self) -> List[str]:
|
||||
"""列出所有资源"""
|
||||
return list(self.resources.keys())
|
||||
|
||||
|
||||
class LaiYuLiquidContainer:
|
||||
"""LaiYu_Liquid 容器类"""
|
||||
|
||||
def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, container_type: str = "", volume: float = 0.0, max_volume: float = 1000.0, lid_height: float = 0.0):
|
||||
self.name = name
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
self.lid_height = lid_height
|
||||
self.container_type = container_type
|
||||
self.volume = volume
|
||||
self.max_volume = max_volume
|
||||
self.last_updated = time.time()
|
||||
self.child_resources = {} # 存储子资源
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.volume <= 0.0
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
return self.volume >= self.max_volume
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
return max(0.0, self.max_volume - self.volume)
|
||||
|
||||
def add_volume(self, volume: float) -> bool:
|
||||
"""添加体积"""
|
||||
if self.volume + volume <= self.max_volume:
|
||||
self.volume += volume
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_volume(self, volume: float) -> bool:
|
||||
"""移除体积"""
|
||||
if self.volume >= volume:
|
||||
self.volume -= volume
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def assign_child_resource(self, resource, location=None):
|
||||
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
|
||||
if hasattr(resource, 'name'):
|
||||
self.child_resources[resource.name] = {
|
||||
'resource': resource,
|
||||
'location': location
|
||||
}
|
||||
|
||||
|
||||
class LaiYuLiquidTipRack:
|
||||
"""LaiYu_Liquid 吸头架类"""
|
||||
|
||||
def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, tip_count: int = 96, tip_volume: float = 1000.0):
|
||||
self.name = name
|
||||
self.size_x = size_x
|
||||
self.size_y = size_y
|
||||
self.size_z = size_z
|
||||
self.tip_count = tip_count
|
||||
self.tip_volume = tip_volume
|
||||
self.tips_available = [True] * tip_count
|
||||
self.child_resources = {} # 存储子资源
|
||||
|
||||
@property
|
||||
def available_tips(self) -> int:
|
||||
return sum(self.tips_available)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.available_tips == 0
|
||||
|
||||
def pick_tip(self, position: int) -> bool:
|
||||
"""拾取吸头"""
|
||||
if 0 <= position < self.tip_count and self.tips_available[position]:
|
||||
self.tips_available[position] = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_tip(self, position: int) -> bool:
|
||||
"""检查位置是否有吸头"""
|
||||
if 0 <= position < self.tip_count:
|
||||
return self.tips_available[position]
|
||||
return False
|
||||
|
||||
def assign_child_resource(self, resource, location=None):
|
||||
"""分配子资源到指定位置"""
|
||||
self.child_resources[resource.name] = {
|
||||
'resource': resource,
|
||||
'location': location
|
||||
}
|
||||
|
||||
|
||||
def get_module_info():
|
||||
"""获取模块信息"""
|
||||
return {
|
||||
"name": "LaiYu_Liquid",
|
||||
"version": "1.0.0",
|
||||
"description": "LaiYu液体处理工作站模块,提供移液器控制、XYZ轴控制和资源管理功能",
|
||||
"author": "UniLabOS Team",
|
||||
"capabilities": [
|
||||
"移液器控制",
|
||||
"XYZ轴运动控制",
|
||||
"吸头架管理",
|
||||
"板和容器管理",
|
||||
"资源位置管理"
|
||||
],
|
||||
"dependencies": {
|
||||
"required": ["serial"],
|
||||
"optional": ["pylabrobot"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LaiYuLiquidBackend:
|
||||
"""LaiYu_Liquid 硬件通信后端"""
|
||||
|
||||
def __init__(self, config: LaiYuLiquidConfig, deck: Optional['LaiYuLiquidDeck'] = None):
|
||||
self.config = config
|
||||
self.deck = deck # 工作台引用,用于获取资源位置信息
|
||||
self.pipette_controller = None
|
||||
self.xyz_controller = None
|
||||
self.is_connected = False
|
||||
self.is_initialized = False
|
||||
|
||||
# 状态跟踪
|
||||
self.current_position = (0.0, 0.0, 0.0)
|
||||
self.tip_attached = False
|
||||
self.current_volume = 0.0
|
||||
|
||||
def _validate_position(self, x: float, y: float, z: float) -> bool:
|
||||
"""验证位置是否在安全范围内"""
|
||||
try:
|
||||
# 检查X轴范围
|
||||
if not (0 <= x <= self.config.deck_width):
|
||||
logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]")
|
||||
return False
|
||||
|
||||
# 检查Y轴范围
|
||||
if not (0 <= y <= self.config.deck_height):
|
||||
logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]")
|
||||
return False
|
||||
|
||||
# 检查Z轴范围(负值表示向下,0为工作台表面)
|
||||
if not (-self.config.deck_depth <= z <= self.config.safe_height):
|
||||
logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"位置验证失败: {e}")
|
||||
return False
|
||||
|
||||
def _check_hardware_ready(self) -> bool:
|
||||
"""检查硬件是否准备就绪"""
|
||||
if not self.is_connected:
|
||||
logger.error("设备未连接")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE:
|
||||
if self.xyz_controller is None:
|
||||
logger.error("XYZ控制器未初始化")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def emergency_stop(self) -> bool:
|
||||
"""紧急停止所有运动"""
|
||||
try:
|
||||
logger.warning("执行紧急停止")
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 停止XYZ控制器
|
||||
await self.xyz_controller.stop_all_motion()
|
||||
logger.info("XYZ控制器已停止")
|
||||
|
||||
if self.pipette_controller:
|
||||
# 停止移液器控制器
|
||||
await self.pipette_controller.stop()
|
||||
logger.info("移液器控制器已停止")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"紧急停止失败: {e}")
|
||||
return False
|
||||
|
||||
async def move_to_safe_position(self) -> bool:
|
||||
"""移动到安全位置"""
|
||||
try:
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
safe_position = (
|
||||
self.config.deck_width / 2, # 工作台中心X
|
||||
self.config.deck_height / 2, # 工作台中心Y
|
||||
self.config.safe_height # 安全高度Z
|
||||
)
|
||||
|
||||
if not self._validate_position(*safe_position):
|
||||
logger.error("安全位置无效")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
await self.xyz_controller.move_to_work_coord(*safe_position)
|
||||
self.current_position = safe_position
|
||||
logger.info(f"已移动到安全位置: {safe_position}")
|
||||
return True
|
||||
else:
|
||||
# 模拟模式
|
||||
self.current_position = safe_position
|
||||
logger.info("模拟移动到安全位置")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"移动到安全位置失败: {e}")
|
||||
return False
|
||||
|
||||
async def setup(self) -> bool:
|
||||
"""设置硬件连接"""
|
||||
try:
|
||||
if CONTROLLERS_AVAILABLE:
|
||||
# 初始化移液器控制器
|
||||
self.pipette_controller = PipetteController(
|
||||
port=self.config.port,
|
||||
address=self.config.address
|
||||
)
|
||||
|
||||
# 初始化XYZ控制器
|
||||
machine_config = MachineConfig()
|
||||
self.xyz_controller = XYZController(
|
||||
port=self.config.port,
|
||||
baudrate=self.config.baudrate,
|
||||
machine_config=machine_config
|
||||
)
|
||||
|
||||
# 连接设备
|
||||
pipette_connected = await asyncio.to_thread(self.pipette_controller.connect)
|
||||
xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device)
|
||||
|
||||
if pipette_connected and xyz_connected:
|
||||
self.is_connected = True
|
||||
logger.info("LaiYu_Liquid 硬件连接成功")
|
||||
return True
|
||||
else:
|
||||
logger.error("LaiYu_Liquid 硬件连接失败")
|
||||
return False
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("LaiYu_Liquid 运行在模拟模式")
|
||||
self.is_connected = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""停止设备"""
|
||||
try:
|
||||
if self.pipette_controller and hasattr(self.pipette_controller, 'disconnect'):
|
||||
await asyncio.to_thread(self.pipette_controller.disconnect)
|
||||
|
||||
if self.xyz_controller and hasattr(self.xyz_controller, 'disconnect'):
|
||||
await asyncio.to_thread(self.xyz_controller.disconnect)
|
||||
|
||||
self.is_connected = False
|
||||
self.is_initialized = False
|
||||
logger.info("LaiYu_Liquid 已停止")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 停止失败: {e}")
|
||||
|
||||
async def move_to(self, x: float, y: float, z: float) -> bool:
|
||||
"""移动到指定位置"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
# 模拟移动
|
||||
await asyncio.sleep(0.1) # 模拟移动时间
|
||||
self.current_position = (x, y, z)
|
||||
logger.debug(f"移动到位置: ({x}, {y}, {z})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"移动失败: {e}")
|
||||
return False
|
||||
|
||||
async def pick_up_tip(self, tip_rack: str, position: int) -> bool:
|
||||
"""拾取吸头 - 包含真正的Z轴下降控制"""
|
||||
try:
|
||||
# 硬件准备检查
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
if self.tip_attached:
|
||||
logger.warning("已有吸头附着,无法拾取新吸头")
|
||||
return False
|
||||
|
||||
logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头")
|
||||
|
||||
# 获取枪头架位置信息
|
||||
if self.deck is None:
|
||||
logger.error("工作台未初始化")
|
||||
return False
|
||||
|
||||
tip_position = self.deck.get_position(tip_rack)
|
||||
if tip_position is None:
|
||||
logger.error(f"未找到枪头架 {tip_rack} 的位置信息")
|
||||
return False
|
||||
|
||||
# 计算具体枪头位置(这里简化处理,实际应根据position计算偏移)
|
||||
tip_x, tip_y, tip_z = tip_position
|
||||
|
||||
# 验证所有关键位置的安全性
|
||||
safe_z = tip_z + self.config.tip_approach_height
|
||||
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||
|
||||
if not (self._validate_position(tip_x, tip_y, safe_z) and
|
||||
self._validate_position(tip_x, tip_y, pickup_z) and
|
||||
self._validate_position(tip_x, tip_y, retract_z)):
|
||||
logger.error("枪头拾取位置超出安全范围")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 真实硬件控制流程
|
||||
logger.info("使用真实XYZ控制器进行枪头拾取")
|
||||
|
||||
try:
|
||||
# 1. 移动到枪头上方的安全位置
|
||||
safe_z = tip_z + self.config.tip_approach_height
|
||||
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
|
||||
move_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord,
|
||||
tip_x, tip_y, safe_z
|
||||
)
|
||||
if not move_success:
|
||||
logger.error("移动到枪头上方失败")
|
||||
return False
|
||||
|
||||
# 2. Z轴下降到枪头位置
|
||||
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
|
||||
z_down_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord,
|
||||
tip_x, tip_y, pickup_z
|
||||
)
|
||||
if not z_down_success:
|
||||
logger.error("Z轴下降到枪头位置失败")
|
||||
return False
|
||||
|
||||
# 3. 等待一小段时间确保枪头牢固附着
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# 4. Z轴上升到回退高度
|
||||
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
|
||||
z_up_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord,
|
||||
tip_x, tip_y, retract_z
|
||||
)
|
||||
if not z_up_success:
|
||||
logger.error("Z轴上升失败")
|
||||
return False
|
||||
|
||||
# 5. 更新当前位置
|
||||
self.current_position = (tip_x, tip_y, retract_z)
|
||||
|
||||
except Exception as move_error:
|
||||
logger.error(f"枪头拾取过程中发生错误: {move_error}")
|
||||
# 尝试移动到安全位置
|
||||
if self.config.emergency_stop_enabled:
|
||||
await self.emergency_stop()
|
||||
await self.move_to_safe_position()
|
||||
return False
|
||||
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("模拟模式:执行枪头拾取动作")
|
||||
await asyncio.sleep(1.0) # 模拟整个拾取过程的时间
|
||||
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
|
||||
|
||||
# 6. 标记枪头已附着
|
||||
self.tip_attached = True
|
||||
logger.info("吸头拾取成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"拾取吸头失败: {e}")
|
||||
return False
|
||||
|
||||
async def drop_tip(self, location: str = "trash") -> bool:
|
||||
"""丢弃吸头 - 包含真正的Z轴控制"""
|
||||
try:
|
||||
# 硬件准备检查
|
||||
if not self._check_hardware_ready():
|
||||
return False
|
||||
|
||||
if not self.tip_attached:
|
||||
logger.warning("没有吸头附着,无需丢弃")
|
||||
return True
|
||||
|
||||
logger.info(f"开始丢弃吸头到 {location}")
|
||||
|
||||
# 确定丢弃位置
|
||||
if location == "trash":
|
||||
# 使用配置中的垃圾桶位置
|
||||
drop_x, drop_y, drop_z = self.config.trash_position
|
||||
else:
|
||||
# 尝试从deck获取指定位置
|
||||
if self.deck is None:
|
||||
logger.error("工作台未初始化")
|
||||
return False
|
||||
|
||||
drop_position = self.deck.get_position(location)
|
||||
if drop_position is None:
|
||||
logger.error(f"未找到丢弃位置 {location} 的信息")
|
||||
return False
|
||||
drop_x, drop_y, drop_z = drop_position
|
||||
|
||||
# 验证丢弃位置的安全性
|
||||
safe_z = drop_z + self.config.safe_height
|
||||
drop_height_z = drop_z + self.config.tip_drop_height
|
||||
|
||||
if not (self._validate_position(drop_x, drop_y, safe_z) and
|
||||
self._validate_position(drop_x, drop_y, drop_height_z)):
|
||||
logger.error("枪头丢弃位置超出安全范围")
|
||||
return False
|
||||
|
||||
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||
# 真实硬件控制流程
|
||||
logger.info("使用真实XYZ控制器进行枪头丢弃")
|
||||
|
||||
try:
|
||||
# 1. 移动到丢弃位置上方的安全高度
|
||||
safe_z = drop_z + self.config.tip_drop_height
|
||||
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
|
||||
move_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord,
|
||||
drop_x, drop_y, safe_z
|
||||
)
|
||||
if not move_success:
|
||||
logger.error("移动到丢弃位置上方失败")
|
||||
return False
|
||||
|
||||
# 2. Z轴下降到丢弃高度
|
||||
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
|
||||
z_down_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord,
|
||||
drop_x, drop_y, drop_z
|
||||
)
|
||||
if not z_down_success:
|
||||
logger.error("Z轴下降到丢弃位置失败")
|
||||
return False
|
||||
|
||||
# 3. 执行枪头弹出动作(如果有移液器控制器)
|
||||
if self.pipette_controller:
|
||||
try:
|
||||
# 发送弹出枪头命令
|
||||
await asyncio.to_thread(self.pipette_controller.eject_tip)
|
||||
logger.info("执行枪头弹出命令")
|
||||
except Exception as e:
|
||||
logger.warning(f"枪头弹出命令失败: {e}")
|
||||
|
||||
# 4. 等待一小段时间确保枪头完全脱离
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# 5. Z轴上升到安全高度
|
||||
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
|
||||
z_up_success = await asyncio.to_thread(
|
||||
self.xyz_controller.move_to_work_coord,
|
||||
drop_x, drop_y, safe_z
|
||||
)
|
||||
if not z_up_success:
|
||||
logger.error("Z轴上升失败")
|
||||
return False
|
||||
|
||||
# 6. 更新当前位置
|
||||
self.current_position = (drop_x, drop_y, safe_z)
|
||||
|
||||
except Exception as drop_error:
|
||||
logger.error(f"枪头丢弃过程中发生错误: {drop_error}")
|
||||
# 尝试移动到安全位置
|
||||
if self.config.emergency_stop_enabled:
|
||||
await self.emergency_stop()
|
||||
await self.move_to_safe_position()
|
||||
return False
|
||||
|
||||
else:
|
||||
# 模拟模式
|
||||
logger.info("模拟模式:执行枪头丢弃动作")
|
||||
await asyncio.sleep(0.8) # 模拟整个丢弃过程的时间
|
||||
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
|
||||
|
||||
# 7. 标记枪头已脱离,清空体积
|
||||
self.tip_attached = False
|
||||
self.current_volume = 0.0
|
||||
logger.info("吸头丢弃成功")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"丢弃吸头失败: {e}")
|
||||
return False
|
||||
|
||||
async def aspirate(self, volume: float, location: str) -> bool:
|
||||
"""吸取液体"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
if not self.tip_attached:
|
||||
raise LaiYuLiquidError("没有吸头附着")
|
||||
|
||||
if volume <= 0 or volume > self.config.max_volume:
|
||||
raise LaiYuLiquidError(f"体积超出范围: {volume}")
|
||||
|
||||
# 模拟吸取
|
||||
await asyncio.sleep(0.3)
|
||||
self.current_volume += volume
|
||||
logger.debug(f"从 {location} 吸取 {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"吸取失败: {e}")
|
||||
return False
|
||||
|
||||
async def dispense(self, volume: float, location: str) -> bool:
|
||||
"""分配液体"""
|
||||
try:
|
||||
if not self.is_connected:
|
||||
raise LaiYuLiquidError("设备未连接")
|
||||
|
||||
if not self.tip_attached:
|
||||
raise LaiYuLiquidError("没有吸头附着")
|
||||
|
||||
if volume <= 0 or volume > self.current_volume:
|
||||
raise LaiYuLiquidError(f"分配体积无效: {volume}")
|
||||
|
||||
# 模拟分配
|
||||
await asyncio.sleep(0.3)
|
||||
self.current_volume -= volume
|
||||
logger.debug(f"向 {location} 分配 {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分配失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class LaiYuLiquid:
|
||||
"""LaiYu_Liquid 主要接口类"""
|
||||
|
||||
def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs):
|
||||
# 如果传入了关键字参数,创建配置对象
|
||||
if kwargs and config is None:
|
||||
# 从kwargs中提取配置参数
|
||||
config_params = {}
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(LaiYuLiquidConfig, key):
|
||||
config_params[key] = value
|
||||
self.config = LaiYuLiquidConfig(**config_params)
|
||||
else:
|
||||
self.config = config or LaiYuLiquidConfig()
|
||||
|
||||
# 先创建deck,然后传递给backend
|
||||
self.deck = LaiYuLiquidDeck(self.config)
|
||||
self.backend = LaiYuLiquidBackend(self.config, self.deck)
|
||||
self.is_setup = False
|
||||
|
||||
@property
|
||||
def current_position(self) -> Tuple[float, float, float]:
|
||||
"""获取当前位置"""
|
||||
return self.backend.current_position
|
||||
|
||||
@property
|
||||
def current_volume(self) -> float:
|
||||
"""获取当前体积"""
|
||||
return self.backend.current_volume
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""获取连接状态"""
|
||||
return self.backend.is_connected
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""获取初始化状态"""
|
||||
return self.backend.is_initialized
|
||||
|
||||
@property
|
||||
def tip_attached(self) -> bool:
|
||||
"""获取吸头附着状态"""
|
||||
return self.backend.tip_attached
|
||||
|
||||
async def setup(self) -> bool:
|
||||
"""设置液体处理器"""
|
||||
try:
|
||||
success = await self.backend.setup()
|
||||
if success:
|
||||
self.is_setup = True
|
||||
logger.info("LaiYu_Liquid 设置完成")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""停止液体处理器"""
|
||||
await self.backend.stop()
|
||||
self.is_setup = False
|
||||
|
||||
async def transfer(self, source: str, target: str, volume: float,
|
||||
tip_rack: str = "tip_rack_1", tip_position: int = 0) -> bool:
|
||||
"""液体转移"""
|
||||
try:
|
||||
if not self.is_setup:
|
||||
raise LaiYuLiquidError("设备未设置")
|
||||
|
||||
# 获取源和目标位置
|
||||
source_pos = self.deck.get_position(source)
|
||||
target_pos = self.deck.get_position(target)
|
||||
tip_pos = self.deck.get_position(tip_rack)
|
||||
|
||||
if not all([source_pos, target_pos, tip_pos]):
|
||||
raise LaiYuLiquidError("位置信息不完整")
|
||||
|
||||
# 执行转移步骤
|
||||
steps = [
|
||||
("移动到吸头架", self.backend.move_to(*tip_pos)),
|
||||
("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)),
|
||||
("移动到源位置", self.backend.move_to(*source_pos)),
|
||||
("吸取液体", self.backend.aspirate(volume, source)),
|
||||
("移动到目标位置", self.backend.move_to(*target_pos)),
|
||||
("分配液体", self.backend.dispense(volume, target)),
|
||||
("丢弃吸头", self.backend.drop_tip())
|
||||
]
|
||||
|
||||
for step_name, step_coro in steps:
|
||||
logger.debug(f"执行步骤: {step_name}")
|
||||
success = await step_coro
|
||||
if not success:
|
||||
raise LaiYuLiquidError(f"步骤失败: {step_name}")
|
||||
|
||||
logger.info(f"液体转移完成: {source} -> {target}, {volume} μL")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"液体转移失败: {e}")
|
||||
return False
|
||||
|
||||
def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]):
|
||||
"""添加资源到工作台"""
|
||||
if resource_type == "plate":
|
||||
resource = Plate(name)
|
||||
elif resource_type == "tip_rack":
|
||||
resource = TipRack(name)
|
||||
else:
|
||||
resource = Resource(name)
|
||||
|
||||
self.deck.add_resource(name, resource, position)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""获取设备状态"""
|
||||
return {
|
||||
"connected": self.backend.is_connected,
|
||||
"setup": self.is_setup,
|
||||
"current_position": self.backend.current_position,
|
||||
"tip_attached": self.backend.tip_attached,
|
||||
"current_volume": self.backend.current_volume,
|
||||
"resources": self.deck.list_resources()
|
||||
}
|
||||
|
||||
|
||||
def create_quick_setup() -> LaiYuLiquidDeck:
|
||||
"""
|
||||
创建快速设置的LaiYu液体处理工作站
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidDeck: 配置好的工作台实例
|
||||
"""
|
||||
# 创建默认配置
|
||||
config = LaiYuLiquidConfig()
|
||||
|
||||
# 创建工作台
|
||||
deck = LaiYuLiquidDeck(config)
|
||||
|
||||
# 导入资源创建函数
|
||||
try:
|
||||
from .laiyu_liquid_res import (
|
||||
create_tip_rack_1000ul,
|
||||
create_tip_rack_200ul,
|
||||
create_96_well_plate,
|
||||
create_waste_container
|
||||
)
|
||||
|
||||
# 添加基本资源
|
||||
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||
plate_96 = create_96_well_plate("plate_96")
|
||||
waste = create_waste_container("waste")
|
||||
|
||||
# 添加到工作台
|
||||
deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0))
|
||||
deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0))
|
||||
deck.add_resource("plate_96", plate_96, (250, 50, 0))
|
||||
deck.add_resource("waste", waste, (50, 150, 0))
|
||||
|
||||
except ImportError:
|
||||
# 如果资源模块不可用,创建空的工作台
|
||||
logger.warning("资源模块不可用,创建空的工作台")
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LaiYuLiquid",
|
||||
"LaiYuLiquidBackend",
|
||||
"LaiYuLiquidConfig",
|
||||
"LaiYuLiquidDeck",
|
||||
"LaiYuLiquidContainer",
|
||||
"LaiYuLiquidTipRack",
|
||||
"LaiYuLiquidError",
|
||||
"create_quick_setup",
|
||||
"get_module_info"
|
||||
]
|
||||
42
unilabos/devices/liquid_handling/laiyu/core/__init__.py
Normal file
42
unilabos/devices/liquid_handling/laiyu/core/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LaiYu液体处理设备核心模块
|
||||
|
||||
该模块包含LaiYu液体处理设备的核心功能组件:
|
||||
- LaiYu_Liquid.py: 主设备类和配置管理
|
||||
- abstract_protocol.py: 抽象协议定义
|
||||
- laiyu_liquid_res.py: 设备资源管理
|
||||
|
||||
作者: UniLab团队
|
||||
版本: 2.0.0
|
||||
"""
|
||||
|
||||
from .LaiYu_Liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack,
|
||||
create_quick_setup
|
||||
)
|
||||
|
||||
from .laiyu_liquid_res import (
|
||||
LaiYuLiquidDeck,
|
||||
LaiYuLiquidContainer,
|
||||
LaiYuLiquidTipRack
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 主设备类
|
||||
'LaiYuLiquid',
|
||||
'LaiYuLiquidConfig',
|
||||
|
||||
# 设备资源
|
||||
'LaiYuLiquidDeck',
|
||||
'LaiYuLiquidContainer',
|
||||
'LaiYuLiquidTipRack',
|
||||
|
||||
# 工具函数
|
||||
'create_quick_setup'
|
||||
]
|
||||
529
unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py
Normal file
529
unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
LaiYu_Liquid 抽象协议实现
|
||||
|
||||
该模块提供了液体资源管理和转移的抽象协议,包括:
|
||||
- MaterialResource: 液体资源管理类
|
||||
- transfer_liquid: 液体转移函数
|
||||
- 相关的辅助类和函数
|
||||
|
||||
主要功能:
|
||||
- 管理多孔位的液体资源
|
||||
- 计算和跟踪液体体积
|
||||
- 处理液体转移操作
|
||||
- 提供资源状态查询
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Union, Any, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import uuid
|
||||
import time
|
||||
|
||||
# pylabrobot 导入
|
||||
from pylabrobot.resources import Resource, Well, Plate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiquidType(Enum):
|
||||
"""液体类型枚举"""
|
||||
WATER = "water"
|
||||
ETHANOL = "ethanol"
|
||||
DMSO = "dmso"
|
||||
BUFFER = "buffer"
|
||||
SAMPLE = "sample"
|
||||
REAGENT = "reagent"
|
||||
WASTE = "waste"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiquidInfo:
|
||||
"""液体信息类"""
|
||||
liquid_type: LiquidType = LiquidType.UNKNOWN
|
||||
volume: float = 0.0 # 体积 (μL)
|
||||
concentration: Optional[float] = None # 浓度 (mg/ml, M等)
|
||||
ph: Optional[float] = None # pH值
|
||||
temperature: Optional[float] = None # 温度 (°C)
|
||||
viscosity: Optional[float] = None # 粘度 (cP)
|
||||
density: Optional[float] = None # 密度 (g/ml)
|
||||
description: str = "" # 描述信息
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.liquid_type.value}({self.description})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WellContent:
|
||||
"""孔位内容类"""
|
||||
volume: float = 0.0 # 当前体积 (ul)
|
||||
max_volume: float = 1000.0 # 最大容量 (ul)
|
||||
liquid_info: LiquidInfo = field(default_factory=LiquidInfo)
|
||||
last_updated: float = field(default_factory=time.time)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""检查是否为空"""
|
||||
return self.volume <= 0.0
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
"""检查是否已满"""
|
||||
return self.volume >= self.max_volume
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
"""可用体积"""
|
||||
return max(0.0, self.max_volume - self.volume)
|
||||
|
||||
@property
|
||||
def fill_percentage(self) -> float:
|
||||
"""填充百分比"""
|
||||
return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0
|
||||
|
||||
def can_add_volume(self, volume: float) -> bool:
|
||||
"""检查是否可以添加指定体积"""
|
||||
return (self.volume + volume) <= self.max_volume
|
||||
|
||||
def can_remove_volume(self, volume: float) -> bool:
|
||||
"""检查是否可以移除指定体积"""
|
||||
return self.volume >= volume
|
||||
|
||||
def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool:
|
||||
"""
|
||||
添加液体体积
|
||||
|
||||
Args:
|
||||
volume: 要添加的体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功添加
|
||||
"""
|
||||
if not self.can_add_volume(volume):
|
||||
return False
|
||||
|
||||
self.volume += volume
|
||||
if liquid_info:
|
||||
self.liquid_info = liquid_info
|
||||
self.last_updated = time.time()
|
||||
return True
|
||||
|
||||
def remove_volume(self, volume: float) -> bool:
|
||||
"""
|
||||
移除液体体积
|
||||
|
||||
Args:
|
||||
volume: 要移除的体积 (ul)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
if not self.can_remove_volume(volume):
|
||||
return False
|
||||
|
||||
self.volume -= volume
|
||||
self.last_updated = time.time()
|
||||
|
||||
# 如果完全清空,重置液体信息
|
||||
if self.volume <= 0.0:
|
||||
self.volume = 0.0
|
||||
self.liquid_info = LiquidInfo()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class MaterialResource:
|
||||
"""
|
||||
液体资源管理类
|
||||
|
||||
该类用于管理液体处理过程中的资源状态,包括:
|
||||
- 跟踪多个孔位的液体体积和类型
|
||||
- 计算总体积和可用体积
|
||||
- 处理液体的添加和移除
|
||||
- 提供资源状态查询
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: Resource,
|
||||
wells: Optional[List[Well]] = None,
|
||||
default_max_volume: float = 1000.0
|
||||
):
|
||||
"""
|
||||
初始化材料资源
|
||||
|
||||
Args:
|
||||
resource: pylabrobot 资源对象
|
||||
wells: 孔位列表,如果为None则自动获取
|
||||
default_max_volume: 默认最大体积 (ul)
|
||||
"""
|
||||
self.resource = resource
|
||||
self.resource_id = str(uuid.uuid4())
|
||||
self.default_max_volume = default_max_volume
|
||||
|
||||
# 获取孔位列表
|
||||
if wells is None:
|
||||
if hasattr(resource, 'get_wells'):
|
||||
self.wells = resource.get_wells()
|
||||
elif hasattr(resource, 'wells'):
|
||||
self.wells = resource.wells
|
||||
else:
|
||||
# 如果没有孔位,创建一个虚拟孔位
|
||||
self.wells = [resource]
|
||||
else:
|
||||
self.wells = wells
|
||||
|
||||
# 初始化孔位内容
|
||||
self.well_contents: Dict[str, WellContent] = {}
|
||||
for well in self.wells:
|
||||
well_id = self._get_well_id(well)
|
||||
self.well_contents[well_id] = WellContent(
|
||||
max_volume=default_max_volume
|
||||
)
|
||||
|
||||
logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}")
|
||||
|
||||
def _get_well_id(self, well: Union[Well, Resource]) -> str:
|
||||
"""获取孔位ID"""
|
||||
if hasattr(well, 'name'):
|
||||
return well.name
|
||||
else:
|
||||
return str(id(well))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""资源名称"""
|
||||
return self.resource.name
|
||||
|
||||
@property
|
||||
def total_volume(self) -> float:
|
||||
"""总液体体积"""
|
||||
return sum(content.volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def total_max_volume(self) -> float:
|
||||
"""总最大容量"""
|
||||
return sum(content.max_volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def available_volume(self) -> float:
|
||||
"""总可用体积"""
|
||||
return sum(content.available_volume for content in self.well_contents.values())
|
||||
|
||||
@property
|
||||
def well_count(self) -> int:
|
||||
"""孔位数量"""
|
||||
return len(self.wells)
|
||||
|
||||
@property
|
||||
def empty_wells(self) -> List[str]:
|
||||
"""空孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.is_empty]
|
||||
|
||||
@property
|
||||
def full_wells(self) -> List[str]:
|
||||
"""满孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.is_full]
|
||||
|
||||
@property
|
||||
def occupied_wells(self) -> List[str]:
|
||||
"""有液体的孔位列表"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if not content.is_empty]
|
||||
|
||||
def get_well_content(self, well_id: str) -> Optional[WellContent]:
|
||||
"""获取指定孔位的内容"""
|
||||
return self.well_contents.get(well_id)
|
||||
|
||||
def get_well_volume(self, well_id: str) -> float:
|
||||
"""获取指定孔位的体积"""
|
||||
content = self.get_well_content(well_id)
|
||||
return content.volume if content else 0.0
|
||||
|
||||
def set_well_volume(
|
||||
self,
|
||||
well_id: str,
|
||||
volume: float,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
设置指定孔位的体积
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功设置
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
if volume > content.max_volume:
|
||||
logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}")
|
||||
return False
|
||||
|
||||
content.volume = max(0.0, volume)
|
||||
if liquid_info:
|
||||
content.liquid_info = liquid_info
|
||||
content.last_updated = time.time()
|
||||
|
||||
logger.info(f"设置孔位 {well_id} 体积: {volume}ul")
|
||||
return True
|
||||
|
||||
def add_liquid(
|
||||
self,
|
||||
well_id: str,
|
||||
volume: float,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
向指定孔位添加液体
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 添加的体积 (ul)
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 是否成功添加
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
success = content.add_volume(volume, liquid_info)
|
||||
|
||||
if success:
|
||||
logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体")
|
||||
else:
|
||||
logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体")
|
||||
|
||||
return success
|
||||
|
||||
def remove_liquid(self, well_id: str, volume: float) -> bool:
|
||||
"""
|
||||
从指定孔位移除液体
|
||||
|
||||
Args:
|
||||
well_id: 孔位ID
|
||||
volume: 移除的体积 (ul)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
if well_id not in self.well_contents:
|
||||
logger.error(f"孔位 {well_id} 不存在")
|
||||
return False
|
||||
|
||||
content = self.well_contents[well_id]
|
||||
success = content.remove_volume(volume)
|
||||
|
||||
if success:
|
||||
logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体")
|
||||
else:
|
||||
logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体")
|
||||
|
||||
return success
|
||||
|
||||
def find_wells_with_volume(self, min_volume: float) -> List[str]:
|
||||
"""
|
||||
查找具有指定最小体积的孔位
|
||||
|
||||
Args:
|
||||
min_volume: 最小体积 (ul)
|
||||
|
||||
Returns:
|
||||
List[str]: 符合条件的孔位ID列表
|
||||
"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.volume >= min_volume]
|
||||
|
||||
def find_wells_with_space(self, min_space: float) -> List[str]:
|
||||
"""
|
||||
查找具有指定最小空间的孔位
|
||||
|
||||
Args:
|
||||
min_space: 最小空间 (ul)
|
||||
|
||||
Returns:
|
||||
List[str]: 符合条件的孔位ID列表
|
||||
"""
|
||||
return [well_id for well_id, content in self.well_contents.items()
|
||||
if content.available_volume >= min_space]
|
||||
|
||||
def get_status_summary(self) -> Dict[str, Any]:
|
||||
"""获取资源状态摘要"""
|
||||
return {
|
||||
"resource_name": self.name,
|
||||
"resource_id": self.resource_id,
|
||||
"well_count": self.well_count,
|
||||
"total_volume": self.total_volume,
|
||||
"total_max_volume": self.total_max_volume,
|
||||
"available_volume": self.available_volume,
|
||||
"fill_percentage": (self.total_volume / self.total_max_volume) * 100.0,
|
||||
"empty_wells": len(self.empty_wells),
|
||||
"full_wells": len(self.full_wells),
|
||||
"occupied_wells": len(self.occupied_wells)
|
||||
}
|
||||
|
||||
def get_detailed_status(self) -> Dict[str, Any]:
|
||||
"""获取详细状态信息"""
|
||||
well_details = {}
|
||||
for well_id, content in self.well_contents.items():
|
||||
well_details[well_id] = {
|
||||
"volume": content.volume,
|
||||
"max_volume": content.max_volume,
|
||||
"available_volume": content.available_volume,
|
||||
"fill_percentage": content.fill_percentage,
|
||||
"liquid_type": content.liquid_info.liquid_type.value,
|
||||
"description": content.liquid_info.description,
|
||||
"last_updated": content.last_updated
|
||||
}
|
||||
|
||||
return {
|
||||
"summary": self.get_status_summary(),
|
||||
"wells": well_details
|
||||
}
|
||||
|
||||
|
||||
def transfer_liquid(
|
||||
source: MaterialResource,
|
||||
target: MaterialResource,
|
||||
volume: float,
|
||||
source_well_id: Optional[str] = None,
|
||||
target_well_id: Optional[str] = None,
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> bool:
|
||||
"""
|
||||
在两个材料资源之间转移液体
|
||||
|
||||
Args:
|
||||
source: 源资源
|
||||
target: 目标资源
|
||||
volume: 转移体积 (ul)
|
||||
source_well_id: 源孔位ID,如果为None则自动选择
|
||||
target_well_id: 目标孔位ID,如果为None则自动选择
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
bool: 转移是否成功
|
||||
"""
|
||||
try:
|
||||
# 自动选择源孔位
|
||||
if source_well_id is None:
|
||||
available_wells = source.find_wells_with_volume(volume)
|
||||
if not available_wells:
|
||||
logger.error(f"源资源 {source.name} 没有足够体积的孔位")
|
||||
return False
|
||||
source_well_id = available_wells[0]
|
||||
|
||||
# 自动选择目标孔位
|
||||
if target_well_id is None:
|
||||
available_wells = target.find_wells_with_space(volume)
|
||||
if not available_wells:
|
||||
logger.error(f"目标资源 {target.name} 没有足够空间的孔位")
|
||||
return False
|
||||
target_well_id = available_wells[0]
|
||||
|
||||
# 检查源孔位是否有足够液体
|
||||
if not source.get_well_content(source_well_id).can_remove_volume(volume):
|
||||
logger.error(f"源孔位 {source_well_id} 液体不足")
|
||||
return False
|
||||
|
||||
# 检查目标孔位是否有足够空间
|
||||
if not target.get_well_content(target_well_id).can_add_volume(volume):
|
||||
logger.error(f"目标孔位 {target_well_id} 空间不足")
|
||||
return False
|
||||
|
||||
# 获取源液体信息
|
||||
source_content = source.get_well_content(source_well_id)
|
||||
transfer_liquid_info = liquid_info or source_content.liquid_info
|
||||
|
||||
# 执行转移
|
||||
if source.remove_liquid(source_well_id, volume):
|
||||
if target.add_liquid(target_well_id, volume, transfer_liquid_info):
|
||||
logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]")
|
||||
return True
|
||||
else:
|
||||
# 如果目标添加失败,回滚源操作
|
||||
source.add_liquid(source_well_id, volume, source_content.liquid_info)
|
||||
logger.error("目标添加失败,已回滚源操作")
|
||||
return False
|
||||
else:
|
||||
logger.error("源移除失败")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"液体转移失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_material_resource(
|
||||
name: str,
|
||||
resource: Resource,
|
||||
initial_volumes: Optional[Dict[str, float]] = None,
|
||||
liquid_info: Optional[LiquidInfo] = None,
|
||||
max_volume: float = 1000.0
|
||||
) -> MaterialResource:
|
||||
"""
|
||||
创建材料资源的便捷函数
|
||||
|
||||
Args:
|
||||
name: 资源名称
|
||||
resource: pylabrobot 资源对象
|
||||
initial_volumes: 初始体积字典 {well_id: volume}
|
||||
liquid_info: 液体信息
|
||||
max_volume: 最大体积
|
||||
|
||||
Returns:
|
||||
MaterialResource: 创建的材料资源
|
||||
"""
|
||||
material_resource = MaterialResource(
|
||||
resource=resource,
|
||||
default_max_volume=max_volume
|
||||
)
|
||||
|
||||
# 设置初始体积
|
||||
if initial_volumes:
|
||||
for well_id, volume in initial_volumes.items():
|
||||
material_resource.set_well_volume(well_id, volume, liquid_info)
|
||||
|
||||
return material_resource
|
||||
|
||||
|
||||
def batch_transfer_liquid(
|
||||
transfers: List[Tuple[MaterialResource, MaterialResource, float]],
|
||||
liquid_info: Optional[LiquidInfo] = None
|
||||
) -> List[bool]:
|
||||
"""
|
||||
批量液体转移
|
||||
|
||||
Args:
|
||||
transfers: 转移列表 [(source, target, volume), ...]
|
||||
liquid_info: 液体信息
|
||||
|
||||
Returns:
|
||||
List[bool]: 每个转移操作的结果
|
||||
"""
|
||||
results = []
|
||||
|
||||
for source, target, volume in transfers:
|
||||
result = transfer_liquid(source, target, volume, liquid_info=liquid_info)
|
||||
results.append(result)
|
||||
|
||||
if not result:
|
||||
logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}")
|
||||
|
||||
success_count = sum(results)
|
||||
logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功")
|
||||
|
||||
return results
|
||||
954
unilabos/devices/liquid_handling/laiyu/core/laiyu_liquid_res.py
Normal file
954
unilabos/devices/liquid_handling/laiyu/core/laiyu_liquid_res.py
Normal file
@@ -0,0 +1,954 @@
|
||||
"""
|
||||
LaiYu_Liquid 资源定义模块
|
||||
|
||||
该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括:
|
||||
- 各种规格的枪头架
|
||||
- 不同类型的板和容器
|
||||
- 特殊功能位置
|
||||
- 资源创建的便捷函数
|
||||
|
||||
所有资源都基于 deck.json 中的配置参数创建。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from pathlib import Path
|
||||
|
||||
# PyLabRobot 资源导入
|
||||
try:
|
||||
from pylabrobot.resources import (
|
||||
Resource, Deck, Plate, TipRack, Container, Tip,
|
||||
Coordinate
|
||||
)
|
||||
from pylabrobot.resources.tip_rack import TipSpot
|
||||
from pylabrobot.resources.well import Well as PlateWell
|
||||
PYLABROBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
# 如果 PyLabRobot 不可用,创建模拟类
|
||||
PYLABROBOT_AVAILABLE = False
|
||||
|
||||
class Resource:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
class Deck(Resource):
|
||||
pass
|
||||
|
||||
class Plate(Resource):
|
||||
pass
|
||||
|
||||
class TipRack(Resource):
|
||||
pass
|
||||
|
||||
class Container(Resource):
|
||||
pass
|
||||
|
||||
class Tip(Resource):
|
||||
pass
|
||||
|
||||
class TipSpot(Resource):
|
||||
def __init__(self, name: str, **kwargs):
|
||||
super().__init__(name)
|
||||
# 忽略其他参数
|
||||
|
||||
class PlateWell(Resource):
|
||||
pass
|
||||
|
||||
class Coordinate:
|
||||
def __init__(self, x: float, y: float, z: float):
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.z = z
|
||||
|
||||
# 本地导入
|
||||
from .LaiYu_Liquid import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack
|
||||
|
||||
|
||||
def load_deck_config() -> Dict[str, Any]:
|
||||
"""
|
||||
加载工作台配置文件
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 配置字典
|
||||
"""
|
||||
# 优先使用最新的deckconfig.json文件
|
||||
config_path = Path(__file__).parent / "controllers" / "deckconfig.json"
|
||||
|
||||
# 如果最新配置文件不存在,回退到旧配置文件
|
||||
if not config_path.exists():
|
||||
config_path = Path(__file__).parent / "config" / "deck.json"
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
# 如果找不到配置文件,返回默认配置
|
||||
return {
|
||||
"name": "LaiYu_Liquid_Deck",
|
||||
"size_x": 340.0,
|
||||
"size_y": 250.0,
|
||||
"size_z": 160.0
|
||||
}
|
||||
|
||||
|
||||
# 加载配置
|
||||
DECK_CONFIG = load_deck_config()
|
||||
|
||||
|
||||
class LaiYuTipRack1000(LaiYuLiquidTipRack):
|
||||
"""1000μL 枪头架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化1000μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
tip_count=96,
|
||||
tip_volume=1000.0
|
||||
)
|
||||
|
||||
# 创建枪头位置
|
||||
self._create_tip_spots(
|
||||
tip_count=96,
|
||||
tip_spacing=9.0,
|
||||
tip_type="1000ul"
|
||||
)
|
||||
|
||||
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||
"""
|
||||
创建枪头位置 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
tip_count: 枪头数量
|
||||
tip_spacing: 枪头间距
|
||||
tip_type: 枪头类型
|
||||
"""
|
||||
# 从配置文件中获取枪头架的孔位信息
|
||||
config = DECK_CONFIG
|
||||
tip_module = None
|
||||
|
||||
# 查找枪头架模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "tip_rack":
|
||||
tip_module = module
|
||||
break
|
||||
|
||||
if not tip_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * tip_spacing + tip_spacing / 2
|
||||
y = row * tip_spacing + tip_spacing / 2
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=95.0, # 1000ul枪头长度
|
||||
maximal_volume=1000.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=9.0, # 枪头位置宽度
|
||||
size_y=9.0, # 枪头位置深度
|
||||
size_z=95.0, # 枪头位置高度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in tip_module.get("wells", []):
|
||||
spot_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=95.0, # 1000ul枪头长度
|
||||
maximal_volume=1000.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=well_config.get("diameter", 9.0), # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 9.0),
|
||||
size_z=well_config.get("depth", 95.0), # 使用配置中的深度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||
# TipSpot的make_tip函数会在需要时创建Tip
|
||||
|
||||
|
||||
class LaiYuTipRack200(LaiYuLiquidTipRack):
|
||||
"""200μL 枪头架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化200μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
tip_count=96,
|
||||
tip_volume=200.0
|
||||
)
|
||||
|
||||
# 创建枪头位置
|
||||
self._create_tip_spots(
|
||||
tip_count=96,
|
||||
tip_spacing=9.0,
|
||||
tip_type="200ul"
|
||||
)
|
||||
|
||||
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||
"""
|
||||
创建枪头位置
|
||||
|
||||
Args:
|
||||
tip_count: 枪头数量
|
||||
tip_spacing: 枪头间距
|
||||
tip_type: 枪头类型
|
||||
"""
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * tip_spacing + tip_spacing / 2
|
||||
y = row * tip_spacing + tip_spacing / 2
|
||||
|
||||
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的Tip需要特定参数
|
||||
tip = Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=72.0, # 200ul枪头长度
|
||||
maximal_volume=200.0, # 最大体积
|
||||
fitting_depth=8.0 # 安装深度
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip = Tip(name=f"tip_{spot_name}")
|
||||
|
||||
# 创建枪头位置
|
||||
if PYLABROBOT_AVAILABLE:
|
||||
# PyLabRobot的TipSpot需要特定参数
|
||||
tip_spot = TipSpot(
|
||||
name=spot_name,
|
||||
size_x=9.0, # 枪头位置宽度
|
||||
size_y=9.0, # 枪头位置深度
|
||||
size_z=72.0, # 枪头位置高度
|
||||
make_tip=lambda: tip # 创建枪头的函数
|
||||
)
|
||||
else:
|
||||
# 模拟类只需要name
|
||||
tip_spot = TipSpot(name=spot_name)
|
||||
|
||||
# 将吸头位置分配到吸头架
|
||||
self.assign_child_resource(
|
||||
tip_spot,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
|
||||
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||
# TipSpot的make_tip函数会在需要时创建Tip
|
||||
|
||||
|
||||
class LaiYu96WellPlate(LaiYuLiquidContainer):
|
||||
"""96孔板"""
|
||||
|
||||
def __init__(self, name: str, lid_height: float = 0.0):
|
||||
"""
|
||||
初始化96孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=14.22,
|
||||
container_type="96_well_plate",
|
||||
volume=0.0,
|
||||
max_volume=200.0,
|
||||
lid_height=lid_height
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=96,
|
||||
well_volume=200.0,
|
||||
well_spacing=9.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 10.0 # 96孔板孔位深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取96孔板的孔位信息
|
||||
config = DECK_CONFIG
|
||||
plate_module = None
|
||||
|
||||
# 查找96孔板模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "96_well_plate":
|
||||
plate_module = module
|
||||
break
|
||||
|
||||
if not plate_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_spacing * 0.8,
|
||||
size_y=well_spacing * 0.8,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in plate_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuDeepWellPlate(LaiYuLiquidContainer):
|
||||
"""深孔板"""
|
||||
|
||||
def __init__(self, name: str, lid_height: float = 0.0):
|
||||
"""
|
||||
初始化深孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=41.3,
|
||||
container_type="deep_well_plate",
|
||||
volume=0.0,
|
||||
max_volume=2000.0,
|
||||
lid_height=lid_height
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=96,
|
||||
well_volume=2000.0,
|
||||
well_spacing=9.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 35.0 # 深孔板孔位深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取深孔板的孔位信息
|
||||
config = DECK_CONFIG
|
||||
plate_module = None
|
||||
|
||||
# 查找深孔板模块(通常是第二个96孔板模块)
|
||||
plate_modules = []
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "96_well_plate":
|
||||
plate_modules.append(module)
|
||||
|
||||
# 如果有多个96孔板模块,选择第二个作为深孔板
|
||||
if len(plate_modules) > 1:
|
||||
plate_module = plate_modules[1]
|
||||
elif len(plate_modules) == 1:
|
||||
plate_module = plate_modules[0]
|
||||
|
||||
if not plate_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 8
|
||||
cols = 12
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_spacing * 0.8,
|
||||
size_y=well_spacing * 0.8,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in plate_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到板
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuWasteContainer(Container):
|
||||
"""废液容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化废液容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0,
|
||||
max_volume=5000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuWashContainer(Container):
|
||||
"""清洗容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化清洗容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0,
|
||||
max_volume=5000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuReagentContainer(Container):
|
||||
"""试剂容器"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化试剂容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=50.0,
|
||||
size_y=50.0,
|
||||
size_z=100.0,
|
||||
max_volume=2000.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYu8TubeRack(LaiYuLiquidContainer):
|
||||
"""8管试管架"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化8管试管架
|
||||
|
||||
Args:
|
||||
name: 试管架名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=151.0,
|
||||
size_y=75.0,
|
||||
size_z=75.0,
|
||||
container_type="tube_rack",
|
||||
volume=0.0,
|
||||
max_volume=77000.0
|
||||
)
|
||||
|
||||
# 创建孔位
|
||||
self._create_wells(
|
||||
well_count=8,
|
||||
well_volume=77000.0,
|
||||
well_spacing=35.0
|
||||
)
|
||||
|
||||
def get_size_z(self) -> float:
|
||||
"""获取孔位深度"""
|
||||
return 117.0 # 试管深度
|
||||
|
||||
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||
"""
|
||||
创建孔位 - 从配置文件中读取绝对坐标
|
||||
|
||||
Args:
|
||||
well_count: 孔位数量
|
||||
well_volume: 孔位体积
|
||||
well_spacing: 孔位间距
|
||||
"""
|
||||
# 从配置文件中获取8管试管架的孔位信息
|
||||
config = DECK_CONFIG
|
||||
tube_module = None
|
||||
|
||||
# 查找8管试管架模块
|
||||
for module in config.get("children", []):
|
||||
if module.get("type") == "tube_rack":
|
||||
tube_module = module
|
||||
break
|
||||
|
||||
if not tube_module:
|
||||
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||
rows = 2
|
||||
cols = 4
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
well_name = f"{chr(65 + row)}{col + 1}"
|
||||
x = col * well_spacing + well_spacing / 2
|
||||
y = row * well_spacing + well_spacing / 2
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=29.0,
|
||||
size_y=29.0,
|
||||
size_z=self.get_size_z(),
|
||||
max_volume=well_volume
|
||||
)
|
||||
|
||||
# 添加到试管架
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(x, y, 0)
|
||||
)
|
||||
return
|
||||
|
||||
# 使用配置文件中的绝对坐标
|
||||
module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||
|
||||
for well_config in tube_module.get("wells", []):
|
||||
well_name = well_config["id"]
|
||||
well_pos = well_config["position"]
|
||||
|
||||
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||
relative_x = well_pos["x"] - module_position["x"]
|
||||
relative_y = well_pos["y"] - module_position["y"]
|
||||
relative_z = well_pos["z"] - module_position["z"]
|
||||
|
||||
# 创建孔位
|
||||
well = PlateWell(
|
||||
name=well_name,
|
||||
size_x=well_config.get("diameter", 29.0),
|
||||
size_y=well_config.get("diameter", 29.0),
|
||||
size_z=well_config.get("depth", self.get_size_z()),
|
||||
max_volume=well_config.get("volume", well_volume)
|
||||
)
|
||||
|
||||
# 添加到试管架
|
||||
self.assign_child_resource(
|
||||
well,
|
||||
location=Coordinate(relative_x, relative_y, relative_z)
|
||||
)
|
||||
|
||||
|
||||
class LaiYuTipDisposal(Resource):
|
||||
"""枪头废料位置"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化枪头废料位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=100.0,
|
||||
size_y=100.0,
|
||||
size_z=50.0
|
||||
)
|
||||
|
||||
|
||||
class LaiYuMaintenancePosition(Resource):
|
||||
"""维护位置"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
初始化维护位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=50.0,
|
||||
size_y=50.0,
|
||||
size_z=100.0
|
||||
)
|
||||
|
||||
|
||||
# 资源创建函数
|
||||
def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000:
|
||||
"""
|
||||
创建1000μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipRack1000: 1000μL枪头架实例
|
||||
"""
|
||||
return LaiYuTipRack1000(name)
|
||||
|
||||
|
||||
def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200:
|
||||
"""
|
||||
创建200μL枪头架
|
||||
|
||||
Args:
|
||||
name: 枪头架名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipRack200: 200μL枪头架实例
|
||||
"""
|
||||
return LaiYuTipRack200(name)
|
||||
|
||||
|
||||
def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate:
|
||||
"""
|
||||
创建96孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
|
||||
Returns:
|
||||
LaiYu96WellPlate: 96孔板实例
|
||||
"""
|
||||
return LaiYu96WellPlate(name, lid_height)
|
||||
|
||||
|
||||
def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate:
|
||||
"""
|
||||
创建深孔板
|
||||
|
||||
Args:
|
||||
name: 板名称
|
||||
lid_height: 盖子高度
|
||||
|
||||
Returns:
|
||||
LaiYuDeepWellPlate: 深孔板实例
|
||||
"""
|
||||
return LaiYuDeepWellPlate(name, lid_height)
|
||||
|
||||
|
||||
def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack:
|
||||
"""
|
||||
创建8管试管架
|
||||
|
||||
Args:
|
||||
name: 试管架名称
|
||||
|
||||
Returns:
|
||||
LaiYu8TubeRack: 8管试管架实例
|
||||
"""
|
||||
return LaiYu8TubeRack(name)
|
||||
|
||||
|
||||
def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer:
|
||||
"""
|
||||
创建废液容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuWasteContainer: 废液容器实例
|
||||
"""
|
||||
return LaiYuWasteContainer(name)
|
||||
|
||||
|
||||
def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer:
|
||||
"""
|
||||
创建清洗容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuWashContainer: 清洗容器实例
|
||||
"""
|
||||
return LaiYuWashContainer(name)
|
||||
|
||||
|
||||
def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer:
|
||||
"""
|
||||
创建试剂容器
|
||||
|
||||
Args:
|
||||
name: 容器名称
|
||||
|
||||
Returns:
|
||||
LaiYuReagentContainer: 试剂容器实例
|
||||
"""
|
||||
return LaiYuReagentContainer(name)
|
||||
|
||||
|
||||
def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal:
|
||||
"""
|
||||
创建枪头废料位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
|
||||
Returns:
|
||||
LaiYuTipDisposal: 枪头废料位置实例
|
||||
"""
|
||||
return LaiYuTipDisposal(name)
|
||||
|
||||
|
||||
def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition:
|
||||
"""
|
||||
创建维护位置
|
||||
|
||||
Args:
|
||||
name: 位置名称
|
||||
|
||||
Returns:
|
||||
LaiYuMaintenancePosition: 维护位置实例
|
||||
"""
|
||||
return LaiYuMaintenancePosition(name)
|
||||
|
||||
|
||||
def create_standard_deck() -> LaiYuLiquidDeck:
|
||||
"""
|
||||
创建标准工作台配置
|
||||
|
||||
Returns:
|
||||
LaiYuLiquidDeck: 配置好的工作台实例
|
||||
"""
|
||||
# 从配置文件创建工作台
|
||||
deck = LaiYuLiquidDeck(config=DECK_CONFIG)
|
||||
|
||||
return deck
|
||||
|
||||
|
||||
def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]:
|
||||
"""
|
||||
根据名称获取资源
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
name: 资源名称
|
||||
|
||||
Returns:
|
||||
Optional[Resource]: 找到的资源,如果不存在则返回None
|
||||
"""
|
||||
for child in deck.children:
|
||||
if child.name == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]:
|
||||
"""
|
||||
根据类型获取资源列表
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
resource_type: 资源类型
|
||||
|
||||
Returns:
|
||||
List[Resource]: 匹配类型的资源列表
|
||||
"""
|
||||
return [child for child in deck.children if isinstance(child, resource_type)]
|
||||
|
||||
|
||||
def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]:
|
||||
"""
|
||||
列出所有资源
|
||||
|
||||
Args:
|
||||
deck: 工作台实例
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: 按类型分组的资源名称字典
|
||||
"""
|
||||
resources = {
|
||||
"tip_racks": [],
|
||||
"plates": [],
|
||||
"containers": [],
|
||||
"positions": []
|
||||
}
|
||||
|
||||
for child in deck.children:
|
||||
if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)):
|
||||
resources["tip_racks"].append(child.name)
|
||||
elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)):
|
||||
resources["plates"].append(child.name)
|
||||
elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)):
|
||||
resources["containers"].append(child.name)
|
||||
elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)):
|
||||
resources["positions"].append(child.name)
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
# 导出的类别名(向后兼容)
|
||||
TipRack1000ul = LaiYuTipRack1000
|
||||
TipRack200ul = LaiYuTipRack200
|
||||
Plate96Well = LaiYu96WellPlate
|
||||
Plate96DeepWell = LaiYuDeepWellPlate
|
||||
TubeRack8 = LaiYu8TubeRack
|
||||
WasteContainer = LaiYuWasteContainer
|
||||
WashContainer = LaiYuWashContainer
|
||||
ReagentContainer = LaiYuReagentContainer
|
||||
TipDisposal = LaiYuTipDisposal
|
||||
MaintenancePosition = LaiYuMaintenancePosition
|
||||
69
unilabos/devices/liquid_handling/laiyu/docs/CHANGELOG.md
Normal file
69
unilabos/devices/liquid_handling/laiyu/docs/CHANGELOG.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 更新日志
|
||||
|
||||
本文档记录了 LaiYu_Liquid 模块的所有重要变更。
|
||||
|
||||
## [1.0.0] - 2024-01-XX
|
||||
|
||||
### 新增功能
|
||||
- ✅ 完整的液体处理工作站集成
|
||||
- ✅ RS485 通信协议支持
|
||||
- ✅ SOPA 气动式移液器驱动
|
||||
- ✅ XYZ 三轴步进电机控制
|
||||
- ✅ PyLabRobot 兼容后端
|
||||
- ✅ 标准化资源管理系统
|
||||
- ✅ 96孔板、离心管架、枪头架支持
|
||||
- ✅ RViz 可视化后端
|
||||
- ✅ 完整的配置管理系统
|
||||
- ✅ 抽象协议实现
|
||||
- ✅ 生产级错误处理和日志记录
|
||||
|
||||
### 技术特性
|
||||
- **硬件支持**: SOPA移液器 + XYZ三轴运动平台
|
||||
- **通信协议**: RS485总线,波特率115200
|
||||
- **坐标系统**: 机械坐标与工作坐标自动转换
|
||||
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||
- **兼容性**: 完全兼容 PyLabRobot 框架
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
LaiYu_Liquid/
|
||||
├── core/
|
||||
│ └── LaiYu_Liquid.py # 主模块文件
|
||||
├── __init__.py # 模块初始化
|
||||
├── abstract_protocol.py # 抽象协议
|
||||
├── laiyu_liquid_res.py # 资源管理
|
||||
├── rviz_backend.py # RViz后端
|
||||
├── backend/ # 后端驱动
|
||||
├── config/ # 配置文件
|
||||
├── controllers/ # 控制器
|
||||
├── docs/ # 技术文档
|
||||
└── drivers/ # 底层驱动
|
||||
```
|
||||
|
||||
### 已知问题
|
||||
- 无
|
||||
|
||||
### 依赖要求
|
||||
- Python 3.8+
|
||||
- PyLabRobot
|
||||
- pyserial
|
||||
- asyncio
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 版本号格式
|
||||
采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH`
|
||||
|
||||
- **MAJOR**: 不兼容的API变更
|
||||
- **MINOR**: 向后兼容的功能新增
|
||||
- **PATCH**: 向后兼容的问题修复
|
||||
|
||||
### 变更类型
|
||||
- **新增功能**: 新的功能特性
|
||||
- **变更**: 现有功能的变更
|
||||
- **弃用**: 即将移除的功能
|
||||
- **移除**: 已移除的功能
|
||||
- **修复**: 问题修复
|
||||
- **安全**: 安全相关的修复
|
||||
@@ -0,0 +1,267 @@
|
||||
# SOPA气动式移液器RS485控制指令合集
|
||||
|
||||
## 1. RS485通信基本配置
|
||||
|
||||
### 1.1 支持的设备型号
|
||||
- **仅SC-STxxx-00-13支持RS485通信**
|
||||
- 其他型号主要使用CAN通信
|
||||
|
||||
### 1.2 通信参数
|
||||
- **波特率**: 9600, 115200(默认值)
|
||||
- **地址范围**: 1~254个设备,255为广播地址
|
||||
- **通信接口**: RS485差分信号
|
||||
|
||||
### 1.3 引脚分配(10位LIF连接器)
|
||||
- **引脚7**: RS485+ (RS485通信正极)
|
||||
- **引脚8**: RS485- (RS485通信负极)
|
||||
|
||||
## 2. RS485通信协议格式
|
||||
|
||||
### 2.1 发送数据格式
|
||||
```
|
||||
头码 | 地址 | 命令/数据 | 尾码 | 校验和
|
||||
```
|
||||
|
||||
### 2.2 从机回应格式
|
||||
```
|
||||
头码 | 地址 | 数据(固定9字节) | 尾码 | 校验和
|
||||
```
|
||||
|
||||
### 2.3 格式详细说明
|
||||
- **头码**:
|
||||
- 终端调试: '/' (0x2F)
|
||||
- OEM通信: '[' (0x5B)
|
||||
- **地址**: 设备节点地址,1~254,多字节ASCII(注意:地址不可为47,69,91)
|
||||
- **命令/数据**: ASCII格式的命令字符串
|
||||
- **尾码**: 'E' (0x45)
|
||||
- **校验和**: 以上数据的累加值,1字节
|
||||
|
||||
## 3. 初始化和基本控制指令
|
||||
|
||||
### 3.1 初始化指令
|
||||
```bash
|
||||
# 初始化活塞驱动机构
|
||||
HE
|
||||
|
||||
# 示例(OEM通信):
|
||||
# 主机发送: 5B 32 48 45 1A
|
||||
# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6
|
||||
# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC
|
||||
```
|
||||
|
||||
### 3.2 枪头操作指令
|
||||
```bash
|
||||
# 顶出枪头
|
||||
RE
|
||||
|
||||
# 枪头检测状态报告
|
||||
Q28 # 返回枪头存在状态(0=不存在,1=存在)
|
||||
```
|
||||
|
||||
## 4. 移液控制指令
|
||||
|
||||
### 4.1 位置控制指令
|
||||
```bash
|
||||
# 绝对位置移动(微升)
|
||||
A[n]E
|
||||
# 示例:移动到位置0
|
||||
A0E
|
||||
|
||||
# 相对抽吸(向上移动)
|
||||
P[n]E
|
||||
# 示例:抽吸200微升
|
||||
P200E
|
||||
|
||||
# 相对分配(向下移动)
|
||||
D[n]E
|
||||
# 示例:分配200微升
|
||||
D200E
|
||||
```
|
||||
|
||||
### 4.2 速度设置指令
|
||||
```bash
|
||||
# 设置最高速度(0.1ul/秒为单位)
|
||||
s[n]E
|
||||
# 示例:设置最高速度为2000(200ul/秒)
|
||||
s2000E
|
||||
|
||||
# 设置启动速度
|
||||
b[n]E
|
||||
# 示例:设置启动速度为100(10ul/秒)
|
||||
b100E
|
||||
|
||||
# 设置断流速度
|
||||
c[n]E
|
||||
# 示例:设置断流速度为100(10ul/秒)
|
||||
c100E
|
||||
|
||||
# 设置加速度
|
||||
a[n]E
|
||||
# 示例:设置加速度为30000
|
||||
a30000E
|
||||
```
|
||||
|
||||
## 5. 液体检测和安全控制指令
|
||||
|
||||
### 5.1 吸排液检测控制
|
||||
```bash
|
||||
# 开启吸排液检测
|
||||
f1E # 开启
|
||||
f0E # 关闭
|
||||
|
||||
# 设置空吸门限
|
||||
$[n]E
|
||||
# 示例:设置空吸门限为4
|
||||
$4E
|
||||
|
||||
# 设置泡沫门限
|
||||
![n]E
|
||||
# 示例:设置泡沫门限为20
|
||||
!20E
|
||||
|
||||
# 设置堵塞门限
|
||||
%[n]E
|
||||
# 示例:设置堵塞门限为350
|
||||
%350E
|
||||
```
|
||||
|
||||
### 5.2 液位检测指令
|
||||
```bash
|
||||
# 压力式液位检测
|
||||
m0E # 设置为压力探测模式
|
||||
L[n]E # 执行液位检测,[n]为灵敏度(3~40)
|
||||
k[n]E # 设置检测速度(100~2000)
|
||||
|
||||
# 电容式液位检测
|
||||
m1E # 设置为电容探测模式
|
||||
```
|
||||
|
||||
## 6. 状态查询和报告指令
|
||||
|
||||
### 6.1 基本状态查询
|
||||
```bash
|
||||
# 查询固件版本
|
||||
V
|
||||
|
||||
# 查询设备状态
|
||||
Q[n]
|
||||
# 常用查询参数:
|
||||
Q01 # 报告加速度
|
||||
Q02 # 报告启动速度
|
||||
Q03 # 报告断流速度
|
||||
Q06 # 报告最大速度
|
||||
Q08 # 报告节点地址
|
||||
Q11 # 报告波特率
|
||||
Q18 # 报告当前位置
|
||||
Q28 # 报告枪头存在状态
|
||||
Q29 # 报告校准系数
|
||||
Q30 # 报告空吸门限
|
||||
Q31 # 报告堵针门限
|
||||
Q32 # 报告泡沫门限
|
||||
```
|
||||
|
||||
## 7. 配置和校准指令
|
||||
|
||||
### 7.1 校准参数设置
|
||||
```bash
|
||||
# 设置校准系数
|
||||
j[n]E
|
||||
# 示例:设置校准系数为1.04
|
||||
j1.04E
|
||||
|
||||
# 设置补偿偏差
|
||||
e[n]E
|
||||
# 示例:设置补偿偏差为2.03
|
||||
e2.03E
|
||||
|
||||
# 设置吸头容量
|
||||
C[n]E
|
||||
# 示例:设置1000ul吸头
|
||||
C1000E
|
||||
```
|
||||
|
||||
### 7.2 高级控制参数
|
||||
```bash
|
||||
# 设置回吸粘度
|
||||
][n]E
|
||||
# 示例:设置回吸粘度为30
|
||||
]30E
|
||||
|
||||
# 延时控制
|
||||
M[n]E
|
||||
# 示例:延时1000毫秒
|
||||
M1000E
|
||||
```
|
||||
|
||||
## 8. 复合操作指令示例
|
||||
|
||||
### 8.1 标准移液操作
|
||||
```bash
|
||||
# 完整的200ul移液操作
|
||||
a30000b200c200s2000P200E
|
||||
# 解析:设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行
|
||||
```
|
||||
|
||||
### 8.2 带检测的移液操作
|
||||
```bash
|
||||
# 带空吸检测的200ul抽吸
|
||||
a30000b200c200s2000f1P200f0E
|
||||
# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行
|
||||
```
|
||||
|
||||
### 8.3 液面检测操作
|
||||
```bash
|
||||
# 压力式液面检测
|
||||
m0k200L5E
|
||||
# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测
|
||||
|
||||
# 电容式液面检测
|
||||
m1L3E
|
||||
# 解析:电容模式 + 灵敏度3 + 执行检测
|
||||
```
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
### 9.1 状态字节说明
|
||||
- **00h**: 无错误
|
||||
- **01h**: 上次动作未完成
|
||||
- **02h**: 设备未初始化
|
||||
- **03h**: 设备过载
|
||||
- **04h**: 无效指令
|
||||
- **05h**: 液位探测故障
|
||||
- **0Dh**: 空吸
|
||||
- **0Eh**: 堵针
|
||||
- **10h**: 泡沫
|
||||
- **11h**: 吸液超过吸头容量
|
||||
|
||||
### 9.2 错误查询
|
||||
```bash
|
||||
# 查询当前错误状态
|
||||
Q # 返回状态字节和错误代码
|
||||
```
|
||||
|
||||
## 10. 通信示例
|
||||
|
||||
### 10.1 基本通信流程
|
||||
1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成
|
||||
2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据
|
||||
|
||||
### 10.2 快速指令表
|
||||
| 操作 | 指令 | 说明 |
|
||||
|------|------|------|
|
||||
| 初始化 | `HE` | 初始化设备 |
|
||||
| 退枪头 | `RE` | 顶出枪头 |
|
||||
| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 |
|
||||
| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 |
|
||||
| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 |
|
||||
| 压力液面检测 | `m0k200L5E` | pLLD检测 |
|
||||
| 电容液面检测 | `m1L3E` | cLLD检测 |
|
||||
|
||||
## 11. 注意事项
|
||||
|
||||
1. **地址限制**: RS485地址不可设为47、69、91
|
||||
2. **校验和**: 终端调试时不关心校验和,OEM通信需要校验
|
||||
3. **ASCII格式**: 所有命令和参数都使用ASCII字符
|
||||
4. **执行指令**: 大部分命令需要以'E'结尾才能执行
|
||||
5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信
|
||||
6. **波特率设置**: 默认115200,可设置为9600
|
||||
162
unilabos/devices/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md
Normal file
162
unilabos/devices/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 步进电机B系列控制指令详解
|
||||
|
||||
## 基本通信参数
|
||||
- **通信方式**: RS485
|
||||
- **协议**: Modbus
|
||||
- **波特率**: 115200 (默认)
|
||||
- **数据位**: 8位
|
||||
- **停止位**: 1位
|
||||
- **校验位**: 无
|
||||
- **默认站号**: 1 (可设置1-254)
|
||||
|
||||
## 支持的功能码
|
||||
- **03H**: 读取寄存器
|
||||
- **06H**: 写入单个寄存器
|
||||
- **10H**: 写入多个寄存器
|
||||
|
||||
## 寄存器地址表
|
||||
|
||||
### 状态监控寄存器 (只读)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
|
||||
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
|
||||
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
|
||||
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
|
||||
| 05H | 03H | 电流 | 当前工作电流 (mA) |
|
||||
|
||||
### 控制寄存器 (读写)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
|
||||
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
|
||||
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
|
||||
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
|
||||
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
|
||||
|
||||
### 位置模式寄存器
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
|
||||
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
|
||||
| 12H | 03H/06H/10H | 保留 | - |
|
||||
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
|
||||
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
|
||||
|
||||
### 速度模式寄存器
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 60H | 03H/06H/10H | 保留 | - |
|
||||
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
|
||||
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||
|
||||
### 设备参数寄存器
|
||||
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|
||||
|------|--------|------|--------|------|
|
||||
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
|
||||
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
|
||||
| E2H | 03H/06H/10H | 保留 | 0258H | - |
|
||||
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
|
||||
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
|
||||
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
|
||||
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
|
||||
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
|
||||
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
|
||||
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
|
||||
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
|
||||
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
|
||||
|
||||
### 版本信息寄存器 (只读)
|
||||
| 地址 | 功能码 | 内容 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| F0H | 03H | 版本号 | 固件版本信息 |
|
||||
| F1H-F4H | 03H | 型号 | 产品型号信息 |
|
||||
|
||||
## 常用控制指令示例
|
||||
|
||||
### 读取电机状态
|
||||
```
|
||||
发送: 01 03 00 00 00 01 84 0A
|
||||
接收: 01 03 02 00 01 79 84
|
||||
说明: 电机状态为0001H (正在运行)
|
||||
```
|
||||
|
||||
### 读取当前位置
|
||||
```
|
||||
发送: 01 03 00 01 00 02 95 CB
|
||||
接收: 01 03 04 00 19 00 00 2B F4
|
||||
说明: 当前位置为1638400步 (100圈)
|
||||
```
|
||||
|
||||
### 停止电机
|
||||
```
|
||||
发送: 01 10 00 04 00 01 02 00 00 A7 D4
|
||||
接收: 01 10 00 04 00 01 40 08
|
||||
说明: 急停指令
|
||||
```
|
||||
|
||||
### 位置模式运动
|
||||
```
|
||||
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
|
||||
接收: 01 10 00 10 00 06 41 CE
|
||||
说明: 以5000rpm速度运动到1638400步位置
|
||||
```
|
||||
|
||||
### 速度模式 - 正转
|
||||
```
|
||||
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
|
||||
接收: 01 10 00 60 00 04 C1 D4
|
||||
说明: 以5000rpm速度正转
|
||||
```
|
||||
|
||||
### 速度模式 - 反转
|
||||
```
|
||||
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
|
||||
接收: 01 10 00 60 00 04 C1 D4
|
||||
说明: 以5000rpm速度反转 (EC78H = -5000)
|
||||
```
|
||||
|
||||
### 设置设备地址
|
||||
```
|
||||
发送: 00 06 00 E0 00 02 C9 F1
|
||||
接收: 00 06 00 E0 00 02 C9 F1
|
||||
说明: 将设备地址设置为2
|
||||
```
|
||||
|
||||
## 错误码
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 0001H | 功能码错误 |
|
||||
| 0002H | 地址错误 |
|
||||
| 0003H | 长度错误 |
|
||||
|
||||
## CRC校验算法
|
||||
```c
|
||||
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
|
||||
int wCrc = 0x0000FFFF;
|
||||
byte[] CRC = new byte[2];
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
wCrc ^= ((data[i + offset]) & 0xFF);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if ((wCrc & 0x00000001) == 1) {
|
||||
wCrc >>= 1;
|
||||
wCrc ^= 0x0000A001;
|
||||
} else {
|
||||
wCrc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
|
||||
CRC[0] = (byte) (wCrc & 0x000000FF);
|
||||
return CRC;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
1. 所有16位数据采用大端序传输
|
||||
2. 步数计算: 实际步数 = 高位<<16 | 低位
|
||||
3. 负数使用补码表示
|
||||
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
|
||||
5. 光电开关需使用NPN开漏型
|
||||
6. 限位开关: LF正向, LB反向
|
||||
1281
unilabos/devices/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md
Normal file
1281
unilabos/devices/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md
Normal file
File diff suppressed because it is too large
Load Diff
285
unilabos/devices/liquid_handling/laiyu/docs/readme.md
Normal file
285
unilabos/devices/liquid_handling/laiyu/docs/readme.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# LaiYu_Liquid 液体处理工作站 - 生产就绪版本
|
||||
|
||||
## 概述
|
||||
|
||||
LaiYu_Liquid 是一个完全集成到 UniLabOS 系统的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
|
||||
|
||||
## 系统组成
|
||||
|
||||
### 硬件组件
|
||||
- **XYZ三轴运动平台**: 3个RS485步进电机驱动(地址:X轴=0x01, Y轴=0x02, Z轴=0x03)
|
||||
- **SOPA气动式移液器**: RS485总线控制,支持精密液体处理操作
|
||||
- **通信接口**: RS485转USB模块,默认波特率115200
|
||||
- **机械结构**: 稳固工作台面,支持离心管架、96孔板等标准实验耗材
|
||||
|
||||
### 软件架构
|
||||
- **驱动层**: 底层硬件通信驱动,支持RS485协议
|
||||
- **控制层**: 高级控制逻辑和坐标系管理
|
||||
- **抽象层**: 完全符合UniLabOS标准的液体处理接口
|
||||
- **资源层**: 标准化的实验器具和耗材管理
|
||||
|
||||
## 🎯 生产就绪组件
|
||||
|
||||
### ✅ 核心驱动程序 (`drivers/`)
|
||||
- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
|
||||
- 支持液体吸取、分配、检测
|
||||
- 完整的错误处理和状态管理
|
||||
- 生产级别的通信协议实现
|
||||
|
||||
- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
|
||||
- 精确的位置控制和运动规划
|
||||
- 安全限位和错误检测
|
||||
- 高性能运动控制算法
|
||||
|
||||
### ✅ 高级控制器 (`controllers/`)
|
||||
- **`pipette_controller.py`** - 移液控制器
|
||||
- 封装高级液体处理功能
|
||||
- 支持多种液体类型和处理参数
|
||||
- 智能错误恢复机制
|
||||
|
||||
- **`xyz_controller.py`** - XYZ运动控制器
|
||||
- 坐标系管理和转换
|
||||
- 运动路径优化
|
||||
- 安全运动控制
|
||||
|
||||
### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
|
||||
- **完整的液体处理抽象接口**
|
||||
- **标准化的资源管理系统**
|
||||
- **与PyLabRobot兼容的后端实现**
|
||||
- **生产级别的错误处理和日志记录**
|
||||
|
||||
### ✅ 资源管理系统
|
||||
- **`laiyu_liquid_res.py`** - 标准化资源定义
|
||||
- 96孔板、离心管架、枪头架等标准器具
|
||||
- 自动化的资源创建和配置函数
|
||||
- 与工作台布局的完美集成
|
||||
|
||||
### ✅ 配置管理 (`config/`)
|
||||
- **`config/deck.json`** - 工作台布局配置
|
||||
- 精确的空间定义和槽位管理
|
||||
- 支持多种实验器具的标准化放置
|
||||
- 可扩展的配置架构
|
||||
|
||||
- **`__init__.py`** - 模块集成和导出
|
||||
- 完整的API导出和版本管理
|
||||
- 依赖检查和安装验证
|
||||
- 专业的模块信息展示
|
||||
|
||||
### ✅ 可视化支持
|
||||
- **`rviz_backend.py`** - RViz可视化后端
|
||||
- 实时运动状态可视化
|
||||
- 液体处理过程监控
|
||||
- 与ROS系统的无缝集成
|
||||
|
||||
## 🚀 核心功能特性
|
||||
|
||||
### 液体处理能力
|
||||
- **精密体积控制**: 支持1-1000μL精确分配
|
||||
- **多种液体类型**: 水性、有机溶剂、粘稠液体等
|
||||
- **智能检测**: 液位检测、气泡检测、堵塞检测
|
||||
- **自动化流程**: 完整的吸取-转移-分配工作流
|
||||
|
||||
### 运动控制系统
|
||||
- **三轴精密定位**: 微米级精度控制
|
||||
- **路径优化**: 智能运动规划和碰撞避免
|
||||
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||
- **坐标系管理**: 工作坐标与机械坐标的自动转换
|
||||
|
||||
### 资源管理
|
||||
- **标准化器具**: 支持96孔板、离心管架、枪头架等
|
||||
- **状态跟踪**: 实时监控液体体积、枪头状态等
|
||||
- **自动配置**: 基于JSON的灵活配置系统
|
||||
- **扩展性**: 易于添加新的器具类型
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
LaiYu_Liquid/
|
||||
├── __init__.py # 模块初始化和API导出
|
||||
├── readme.md # 本文档
|
||||
├── rviz_backend.py # RViz可视化后端
|
||||
├── backend/ # 后端驱动模块
|
||||
│ ├── __init__.py
|
||||
│ └── laiyu_backend.py # PyLabRobot兼容后端
|
||||
├── core/ # 核心模块
|
||||
│ ├── core/
|
||||
│ │ └── LaiYu_Liquid.py # 主设备类
|
||||
│ ├── abstract_protocol.py # 抽象协议
|
||||
│ └── laiyu_liquid_res.py # 设备资源定义
|
||||
├── config/ # 配置文件目录
|
||||
│ └── deck.json # 工作台布局配置
|
||||
├── controllers/ # 高级控制器
|
||||
│ ├── __init__.py
|
||||
│ ├── pipette_controller.py # 移液控制器
|
||||
│ └── xyz_controller.py # XYZ运动控制器
|
||||
├── docs/ # 技术文档
|
||||
│ ├── SOPA气动式移液器RS485控制指令.md
|
||||
│ ├── 步进电机控制指令.md
|
||||
│ └── hardware/ # 硬件相关文档
|
||||
├── drivers/ # 底层驱动程序
|
||||
│ ├── __init__.py
|
||||
│ ├── sopa_pipette_driver.py # SOPA移液器驱动
|
||||
│ └── xyz_stepper_driver.py # XYZ步进电机驱动
|
||||
└── tests/ # 测试文件
|
||||
```
|
||||
|
||||
## 🔧 快速开始
|
||||
|
||||
### 1. 安装和验证
|
||||
|
||||
```python
|
||||
# 验证模块安装
|
||||
from unilabos.devices.laiyu_liquid import (
|
||||
LaiYuLiquid,
|
||||
LaiYuLiquidConfig,
|
||||
create_quick_setup,
|
||||
print_module_info
|
||||
)
|
||||
|
||||
# 查看模块信息
|
||||
print_module_info()
|
||||
|
||||
# 快速创建默认资源
|
||||
resources = create_quick_setup()
|
||||
print(f"已创建 {len(resources)} 个资源")
|
||||
```
|
||||
|
||||
### 2. 基本使用示例
|
||||
|
||||
```python
|
||||
from unilabos.devices.LaiYu_Liquid import (
|
||||
create_quick_setup,
|
||||
create_96_well_plate,
|
||||
create_laiyu_backend
|
||||
)
|
||||
|
||||
# 快速创建默认资源
|
||||
resources = create_quick_setup()
|
||||
print(f"创建了以下资源: {list(resources.keys())}")
|
||||
|
||||
# 创建96孔板
|
||||
plate_96 = create_96_well_plate("test_plate")
|
||||
print(f"96孔板包含 {len(plate_96.children)} 个孔位")
|
||||
|
||||
# 创建后端实例(用于PyLabRobot集成)
|
||||
backend = create_laiyu_backend("LaiYu_Device")
|
||||
print(f"后端设备: {backend.name}")
|
||||
```
|
||||
|
||||
### 3. 后端驱动使用
|
||||
|
||||
```python
|
||||
from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
|
||||
|
||||
# 创建后端实例
|
||||
backend = create_laiyu_backend("LaiYu_Liquid_Station")
|
||||
|
||||
# 连接设备
|
||||
await backend.connect()
|
||||
|
||||
# 设备归位
|
||||
await backend.home_device()
|
||||
|
||||
# 获取设备状态
|
||||
status = await backend.get_status()
|
||||
print(f"设备状态: {status}")
|
||||
|
||||
# 断开连接
|
||||
await backend.disconnect()
|
||||
```
|
||||
|
||||
### 4. 资源管理示例
|
||||
|
||||
```python
|
||||
from unilabos.devices.LaiYu_Liquid import (
|
||||
create_centrifuge_tube_rack,
|
||||
create_tip_rack,
|
||||
load_deck_config
|
||||
)
|
||||
|
||||
# 加载工作台配置
|
||||
deck_config = load_deck_config()
|
||||
print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
|
||||
|
||||
# 创建不同类型的资源
|
||||
tube_rack = create_centrifuge_tube_rack("sample_rack")
|
||||
tip_rack = create_tip_rack("tip_rack_200ul")
|
||||
|
||||
print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
|
||||
print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
|
||||
```
|
||||
|
||||
## 🔍 技术架构
|
||||
|
||||
### 坐标系统
|
||||
- **机械坐标**: 基于步进电机的原始坐标系统
|
||||
- **工作坐标**: 用户友好的实验室坐标系统
|
||||
- **自动转换**: 透明的坐标系转换和校准
|
||||
|
||||
### 通信协议
|
||||
- **RS485总线**: 高可靠性工业通信标准
|
||||
- **Modbus协议**: 标准化的设备通信协议
|
||||
- **错误检测**: 完整的通信错误检测和恢复
|
||||
|
||||
### 安全机制
|
||||
- **限位保护**: 硬件和软件双重限位保护
|
||||
- **紧急停止**: 即时停止所有运动和操作
|
||||
- **状态监控**: 实时设备状态监控和报警
|
||||
|
||||
## 🧪 验证和测试
|
||||
|
||||
### 功能验证
|
||||
```python
|
||||
# 验证模块安装
|
||||
from unilabos.devices.laiyu_liquid import validate_installation
|
||||
validate_installation()
|
||||
|
||||
# 查看模块信息
|
||||
from unilabos.devices.laiyu_liquid import print_module_info
|
||||
print_module_info()
|
||||
```
|
||||
|
||||
### 硬件连接测试
|
||||
```python
|
||||
# 测试SOPA移液器连接
|
||||
from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
|
||||
|
||||
config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
|
||||
pipette = SOPAPipette(config)
|
||||
success = pipette.connect()
|
||||
print(f"SOPA连接状态: {'成功' if success else '失败'}")
|
||||
```
|
||||
|
||||
## 📚 维护和支持
|
||||
|
||||
### 日志记录
|
||||
- **结构化日志**: 使用Python logging模块的专业日志记录
|
||||
- **错误追踪**: 详细的错误信息和堆栈跟踪
|
||||
- **性能监控**: 操作时间和性能指标记录
|
||||
|
||||
### 配置管理
|
||||
- **JSON配置**: 灵活的JSON格式配置文件
|
||||
- **参数验证**: 自动配置参数验证和错误提示
|
||||
- **热重载**: 支持配置文件的动态重载
|
||||
|
||||
### 扩展性
|
||||
- **模块化设计**: 易于扩展和定制的模块化架构
|
||||
- **插件接口**: 支持第三方插件和扩展
|
||||
- **API兼容**: 向后兼容的API设计
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
### 常见问题
|
||||
1. **串口权限问题**: 确保用户有串口访问权限
|
||||
2. **依赖库安装**: 使用pip安装所需的Python库
|
||||
3. **设备连接**: 检查RS485适配器和设备地址配置
|
||||
|
||||
### 联系方式
|
||||
- **技术文档**: 查看UniLabOS官方文档
|
||||
- **问题反馈**: 通过GitHub Issues提交问题
|
||||
- **社区支持**: 加入UniLabOS开发者社区
|
||||
|
||||
---
|
||||
|
||||
**LaiYu_Liquid v1.0.0** - 生产就绪的液体处理工作站集成模块
|
||||
© 2024 UniLabOS Project. All rights reserved.
|
||||
30
unilabos/devices/liquid_handling/laiyu/drivers/__init__.py
Normal file
30
unilabos/devices/liquid_handling/laiyu/drivers/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
LaiYu_Liquid 驱动程序模块
|
||||
|
||||
该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序:
|
||||
- SOPA移液器驱动程序
|
||||
- XYZ步进电机驱动程序
|
||||
"""
|
||||
|
||||
# SOPA移液器驱动程序导入
|
||||
from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode
|
||||
|
||||
# XYZ步进电机驱动程序导入
|
||||
from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus
|
||||
|
||||
__all__ = [
|
||||
# SOPA移液器
|
||||
"SOPAPipette",
|
||||
"SOPAConfig",
|
||||
"SOPAStatusCode",
|
||||
|
||||
# XYZ步进电机
|
||||
"StepperMotorDriver",
|
||||
"XYZStepperController",
|
||||
"MotorAxis",
|
||||
"MotorStatus",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "LaiYu_Liquid Driver Team"
|
||||
__description__ = "LaiYu_Liquid 硬件驱动程序集合"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user