From 01ac3415ae907eff6e681f69a05be3c2b0a72d73 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 1 May 2025 14:58:36 +0800 Subject: [PATCH 1/3] Closes #3. Closes #12. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3. Closes #12. * Update README and MQTTClient for installation instructions and code improvements * feat: 支持local_config启动 add: 增加对crt path的说明,为传入config.py的相对路径 move: web component * add: registry description * feat: node_info_update srv fix: OTDeck cant create * close #12 feat: slave node registry * feat: show machine name fix: host node registry not uploaded * feat: add hplc registry * feat: add hplc registry * fix: hplc status typo * fix: devices/ * fix: device.class possible null * fix: HPLC additions with online service * fix: slave mode spin not working * fix: slave mode spin not working * feat: 多ProtocolNode 允许子设备ID相同 feat: 上报发现的ActionClient feat: Host重启动,通过discover机制要求slaveNode重新注册,实现信息及时上报 --------- Co-authored-by: Harvey Que --- test/experiments/HPLC.json | 6 +- unilabos/app/main.py | 17 ++- unilabos/app/mq.py | 9 +- unilabos/app/web/pages.py | 14 +-- unilabos/app/web/templates/status.html | 22 +++- unilabos/app/web/utils/host_utils.py | 13 +- unilabos/app/web/utils/ros_utils.py | 14 ++- unilabos/config/config.py | 1 + .../devices/characterization_optic.yaml | 47 +++++++- unilabos/registry/registry.py | 15 ++- unilabos/resources/graphio.py | 7 +- unilabos/ros/main_slave_run.py | 68 +++++------ unilabos/ros/nodes/base_device_node.py | 58 +++++++-- unilabos/ros/nodes/presets/host_node.py | 112 +++++++++++++++--- unilabos/ros/utils/driver_creator.py | 3 +- 15 files changed, 300 insertions(+), 106 deletions(-) diff --git a/test/experiments/HPLC.json b/test/experiments/HPLC.json index 9e511b3c..6d866a9a 100644 --- a/test/experiments/HPLC.json +++ b/test/experiments/HPLC.json @@ -5,7 +5,7 @@ "name": "HPLC", "parent": null, "type": "device", - "class": "hplc", + "class": "hplc.agilent", "position": { "x": 620.6111111111111, "y": 171, @@ -19,8 +19,8 @@ }, { "id": "BottlesRack3", - "name": "Revvity上样盘3", - "parent": "Revvity", + "name": "上样盘3", + "parent": "HPLC", "type": "plate", "class": null, "position": { diff --git a/unilabos/app/main.py b/unilabos/app/main.py index d418c5c1..f75a3295 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -58,6 +58,18 @@ def parse_args(): default=None, help="配置文件路径,支持.py格式的Python配置文件", ) + parser.add_argument( + "--port", + type=int, + default=8002, + help="信息页web服务的启动端口", + ) + parser.add_argument( + "--open_browser", + type=bool, + default=True, + help="是否在启动时打开信息页", + ) return parser.parse_args() @@ -84,6 +96,9 @@ def main(): # 设置BasicConfig参数 BasicConfig.is_host_mode = not args_dict.get("without_host", False) BasicConfig.slave_no_host = args_dict.get("slave_no_host", False) + machine_name = os.popen("hostname").read().strip() + machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) + BasicConfig.machine_name = machine_name from unilabos.resources.graphio import ( read_node_link_json, @@ -151,7 +166,7 @@ def main(): mqtt_client.start() start_backend(**args_dict) - start_server() + start_server(port=args_dict.get("port", 8002), open_browser=args_dict.get("open_browser", False)) if __name__ == "__main__": diff --git a/unilabos/app/mq.py b/unilabos/app/mq.py index 8ed35f95..a6123fb2 100644 --- a/unilabos/app/mq.py +++ b/unilabos/app/mq.py @@ -146,7 +146,7 @@ class MQTTClient: if self.mqtt_disable: return status = {"data": device_status.get(device_id, {}), "device_id": device_id} - address = f"labs/{MQConfig.lab_id}/devices" + address = f"labs/{MQConfig.lab_id}/devices/" self.client.publish(address, json.dumps(status), qos=2) logger.critical(f"Device status published: address: {address}, {status}") @@ -168,11 +168,8 @@ class MQTTClient: if self.mqtt_disable: return address = f"labs/{MQConfig.lab_id}/actions/" - action_type_name = action_info["title"] - action_info["title"] = action_id - action_data = json.dumps({action_type_name: action_info}, ensure_ascii=False) - self.client.publish(address, action_data, qos=2) - logger.debug(f"Action data published: address: {address}, {action_data}") + self.client.publish(address, json.dumps(action_info), qos=2) + logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}") mqtt_client = MQTTClient() diff --git a/unilabos/app/web/pages.py b/unilabos/app/web/pages.py index 7216a66c..a08cebb5 100644 --- a/unilabos/app/web/pages.py +++ b/unilabos/app/web/pages.py @@ -92,19 +92,7 @@ def setup_web_pages(router: APIRouter) -> None: # 获取已加载的设备 if lab_registry: - # 设备类型 - for device_id, device_info in lab_registry.device_type_registry.items(): - msg = { - "id": device_id, - "name": device_info.get("name", "未命名"), - "file_path": device_info.get("file_path", ""), - "class_json": json.dumps( - device_info.get("class", {}), indent=4, ensure_ascii=False, cls=TypeEncoder - ), - } - mqtt_client.publish_registry(device_id, device_info) - devices.append(msg) - + devices = json.loads(json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder)) # 资源类型 for resource_id, resource_info in lab_registry.resource_type_registry.items(): resources.append( diff --git a/unilabos/app/web/templates/status.html b/unilabos/app/web/templates/status.html index 30c3e6b8..e1105b7b 100644 --- a/unilabos/app/web/templates/status.html +++ b/unilabos/app/web/templates/status.html @@ -96,17 +96,19 @@ 设备ID 命名空间 + 机器名称 状态 {% for device_id, device_info in host_node_info.devices.items() %} {{ device_id }} {{ device_info.namespace }} + {{ device_info.machine_name }} {{ "在线" if device_info.is_online else "离线" }} {% else %} - 没有发现已管理的设备 + 没有发现已管理的设备 {% endfor %} @@ -218,6 +220,7 @@ Device ID 节点名称 命名空间 + 机器名称 状态项 动作数 @@ -227,6 +230,7 @@ {{ device_id }} {{ device_info.node_name }} {{ device_info.namespace }} + {{ device_info.machine_name|default("本地") }} {{ ros_node_info.device_topics.get(device_id, {})|length }} {{ ros_node_info.device_actions.get(device_id, {})|length }} @@ -329,8 +333,13 @@
-
{{ device.class_json }}
- + {% if device.class %} +
{{ device.class | tojson(indent=4) }}
+ {% else %} + +
// No data
+ {% endif %} + {% if device.is_online %}
在线
{% endif %} @@ -362,7 +371,12 @@
diff --git a/unilabos/app/web/utils/host_utils.py b/unilabos/app/web/utils/host_utils.py index 0df5e816..a9070486 100644 --- a/unilabos/app/web/utils/host_utils.py +++ b/unilabos/app/web/utils/host_utils.py @@ -30,20 +30,19 @@ def get_host_node_info() -> Dict[str, Any]: return host_info host_info["available"] = True host_info["devices"] = { - device_id: { + edge_device_id: { "namespace": namespace, - "is_online": f"{namespace}/{device_id}" in host_node._online_devices, - "key": f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}", + "is_online": f"{namespace}/{edge_device_id}" in host_node._online_devices, + "key": f"{namespace}/{edge_device_id}" if namespace.startswith("/") else f"/{namespace}/{edge_device_id}", + "machine_name": host_node.device_machine_names.get(edge_device_id, "未知"), } - for device_id, namespace in host_node.devices_names.items() + for edge_device_id, namespace in host_node.devices_names.items() } # 获取已订阅的主题 host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics)) # 获取动作客户端信息 for action_id, client in host_node._action_clients.items(): - host_info["action_clients"] = { - action_id: get_action_info(client, full_name=action_id) - } + host_info["action_clients"] = {action_id: get_action_info(client, full_name=action_id)} # 获取设备状态 host_info["device_status"] = host_node.device_status diff --git a/unilabos/app/web/utils/ros_utils.py b/unilabos/app/web/utils/ros_utils.py index 01733510..8e1c767c 100644 --- a/unilabos/app/web/utils/ros_utils.py +++ b/unilabos/app/web/utils/ros_utils.py @@ -12,6 +12,7 @@ from unilabos.app.web.utils.action_utils import get_action_info # 存储 ROS 节点信息的全局变量 ros_node_info = {"online_devices": {}, "device_topics": {}, "device_actions": {}} + def get_ros_node_info() -> Dict[str, Any]: """获取 ROS 节点信息,包括设备节点、发布的状态和动作 @@ -35,6 +36,13 @@ def update_ros_node_info() -> Dict[str, Any]: try: from unilabos.ros.nodes.base_device_node import registered_devices + from unilabos.ros.nodes.presets.host_node import HostNode + + # 尝试获取主机节点实例 + host_node = HostNode.get_instance(0) + device_machine_names = {} + if host_node: + device_machine_names = host_node.device_machine_names for device_id, device_info in registered_devices.items(): # 设备基本信息 @@ -42,6 +50,7 @@ def update_ros_node_info() -> Dict[str, Any]: "node_name": device_info["node_name"], "namespace": device_info["namespace"], "uuid": device_info["uuid"], + "machine_name": device_machine_names.get(device_id, "本地"), } # 设备话题(状态)信息 @@ -55,10 +64,7 @@ def update_ros_node_info() -> Dict[str, Any]: } # 设备动作信息 - result["device_actions"][device_id] = { - k: get_action_info(v, k) - for k, v in device_info["actions"].items() - } + result["device_actions"][device_id] = {k: get_action_info(v, k) for k, v in device_info["actions"].items()} # 更新全局变量 ros_node_info = result except Exception as e: diff --git a/unilabos/config/config.py b/unilabos/config/config.py index 5c1f7b9c..3f3d8dd7 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -12,6 +12,7 @@ class BasicConfig: config_path = "" is_host_mode = True # 从registry.py移动过来 slave_no_host = False # 是否跳过rclient.wait_for_service() + machine_name = "undefined" # MQTT配置 diff --git a/unilabos/registry/devices/characterization_optic.yaml b/unilabos/registry/devices/characterization_optic.yaml index 0164ae4a..52ffc87a 100644 --- a/unilabos/registry/devices/characterization_optic.yaml +++ b/unilabos/registry/devices/characterization_optic.yaml @@ -19,6 +19,49 @@ raman_home_made: status: type: string required: - - status + - status additionalProperties: false - type: object \ No newline at end of file + type: object +hplc.agilent: + description: HPLC device + class: + module: unilabos.devices.hplc.AgilentHPLC:HPLCDriver + type: python + status_types: + device_status: String + could_run: Bool + driver_init_ok: Bool + is_running: Bool + finish_status: String + status_text: String + action_value_mappings: + execute_command_from_outer: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success + schema: + properties: + device_status: + type: string + could_run: + type: boolean + driver_init_ok: + type: boolean + is_running: + type: boolean + finish_status: + type: string + status_text: + type: string + required: + - device_status + - could_run + - driver_init_ok + - is_running + - finish_status + - status_text + additionalProperties: false + type: object diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 9c7a95b9..feba3fef 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -1,3 +1,4 @@ +import json import os import sys from pathlib import Path @@ -6,8 +7,9 @@ from typing import Any import yaml from unilabos.utils import logger -from unilabos.ros.msgs.message_converter import msg_converter_manager +from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema from unilabos.utils.decorator import singleton +from unilabos.utils.type_check import TypeEncoder DEFAULT_PATHS = [Path(__file__).absolute().parent] @@ -129,6 +131,7 @@ class Registry: action_config["type"] = self._replace_type_with_class( action_config["type"], device_id, f"动作 {action_name}" ) + action_config["schema"] = ros_action_to_json_schema(action_config["type"]) self.device_type_registry.update(data) @@ -143,6 +146,16 @@ class Registry: f"[UniLab Registry] Device File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}" ) + def obtain_registry_device_info(self): + devices = [] + for device_id, device_info in self.device_type_registry.items(): + msg = { + "id": device_id, + **device_info + } + devices.append(msg) + return devices + # 全局单例实例 lab_registry = Registry() diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index e5c16bc4..71e4aaeb 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -1,4 +1,5 @@ import importlib +import inspect import json from typing import Union import numpy as np @@ -384,7 +385,11 @@ def resource_ulab_to_plr(resource: dict, plr_model=False) -> "ResourcePLR": d = resource_ulab_to_plr_inner(resource) """无法通过Resource进行反序列化,例如TipSpot必须内部序列化好,直接用TipSpot序列化会多参数,导致出错""" from pylabrobot.utils.object_parsing import find_subclass - resource_plr = find_subclass(d["type"], ResourcePLR).deserialize(d, allow_marshal=True) + sub_cls = find_subclass(d["type"], ResourcePLR) + spect = inspect.signature(sub_cls) + if "category" not in spect.parameters: + d.pop("category") + resource_plr = sub_cls.deserialize(d, allow_marshal=True) resource_plr.load_all_state(all_states) return resource_plr diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index 1ae37906..9ac96748 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -1,22 +1,25 @@ +import copy +import json import os -import traceback +import threading from typing import Optional, Dict, Any, List import rclpy from unilabos_msgs.msg import Resource # type: ignore -from unilabos_msgs.srv import ResourceAdd # type: ignore +from unilabos_msgs.srv import ResourceAdd, SerialCommand # type: ignore from rclpy.executors import MultiThreadedExecutor from rclpy.node import Node from rclpy.timer import Timer +from unilabos.registry.registry import lab_registry from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.msgs.message_converter import ( convert_to_ros_msg, ) from unilabos.ros.nodes.presets.host_node import HostNode -from unilabos.ros.x.rclpyx import run_event_loop_in_thread from unilabos.utils import logger from unilabos.config.config import BasicConfig +from unilabos.utils.type_check import TypeEncoder def exit() -> None: @@ -59,16 +62,11 @@ def main( discovery_interval, ) - executor.add_node(host_node) - # run_event_loop_in_thread() + thread = threading.Thread(target=executor.spin, daemon=True, name="host_executor_thread") + thread.start() - try: - executor.spin() - except Exception as e: - logger.error(traceback.format_exc()) - print(f"Exception caught: {e}") - finally: - exit() + while True: + input() def slave( @@ -82,7 +80,7 @@ def slave( """从节点函数""" rclpy.init(args=args) rclpy.__executor = executor = MultiThreadedExecutor() - + devices_config_copy = copy.deepcopy(devices_config) for device_id, device_config in devices_config.items(): d = initialize_device_from_dict(device_id, device_config) if d is None: @@ -93,32 +91,36 @@ def slave( # else: # print(f"Warning: Device {device_id} could not be initialized or is not a valid Node") - machine_name = os.popen("hostname").read().strip() - machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) - n = Node(f"slaveMachine_{machine_name}", parameter_overrides=[]) + n = Node(f"slaveMachine_{BasicConfig.machine_name}", parameter_overrides=[]) executor.add_node(n) - if BasicConfig.slave_no_host: - # 确保ResourceAdd存在 - if "ResourceAdd" in globals(): - rclient = n.create_client(ResourceAdd, "/resources/add") - rclient.wait_for_service() # FIXME 可能一直等待,加一个参数 + thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread") + thread.start() - request = ResourceAdd.Request() - request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources_config] - response = rclient.call_async(request) - else: - print("Warning: ResourceAdd service not available") + if not BasicConfig.slave_no_host: + sclient = n.create_client(SerialCommand, "/node_info_update") + sclient.wait_for_service() - run_event_loop_in_thread() + request = SerialCommand.Request() + request.command = json.dumps({ + "machine_name": BasicConfig.machine_name, + "type": "slave", + "devices_config": devices_config_copy, + "registry_config": lab_registry.obtain_registry_device_info() + }, ensure_ascii=False, cls=TypeEncoder) + response = sclient.call_async(request).result() + logger.info(f"Slave node info updated.") - try: - executor.spin() - except Exception as e: - print(f"Exception caught: {e}") - finally: - exit() + rclient = n.create_client(ResourceAdd, "/resources/add") + rclient.wait_for_service() # FIXME 可能一直等待,加一个参数 + request = ResourceAdd.Request() + request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources_config] + response = rclient.call_async(request).result() + logger.info(f"Slave resource added.") + + while True: + input() if __name__ == "__main__": main() diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 0ff03a68..7e032064 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1,3 +1,4 @@ +import json import threading import time import traceback @@ -13,15 +14,17 @@ from rclpy.action import ActionServer from rclpy.action.server import ServerGoalHandle from rclpy.client import Client from rclpy.callback_groups import ReentrantCallbackGroup +from rclpy.service import Service from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type from unilabos.ros.msgs.message_converter import ( convert_to_ros_msg, convert_from_ros_msg, convert_from_ros_msg_with_mapping, - convert_to_ros_msg_with_mapping, + convert_to_ros_msg_with_mapping, ros_action_to_json_schema, ) -from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList # type: ignore +from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, \ + SerialCommand # type: ignore from unilabos_msgs.msg import Resource # type: ignore from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker @@ -29,7 +32,7 @@ from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import ProtocolNodeCreator, PyLabRobotCreator, DeviceClassCreator from unilabos.utils.async_util import run_async_func from unilabos.utils.log import info, debug, warning, error, critical, logger -from unilabos.utils.type_check import get_type_class +from unilabos.utils.type_check import get_type_class, TypeEncoder T = TypeVar("T") @@ -44,19 +47,17 @@ class ROSLoggerAdapter: @property def identifier(self): - return f"{self.namespace}/{self.node_name}" + return f"{self.namespace}" - def __init__(self, ros_logger, node_name, namespace): + def __init__(self, ros_logger, namespace): """ 初始化日志适配器 Args: ros_logger: ROS2日志记录器 - node_name: 节点名称 namespace: 命名空间 """ self.ros_logger = ros_logger - self.node_name = node_name self.namespace = namespace self.level_2_logger_func = { "info": info, @@ -258,9 +259,9 @@ class BaseROS2DeviceNode(Node, Generic[T]): self.lab_logger().critical("资源跟踪器未初始化,请检查") # 创建自定义日志记录器 - self._lab_logger = ROSLoggerAdapter(self.get_logger(), self.node_name, self.namespace) + self._lab_logger = ROSLoggerAdapter(self.get_logger(), self.namespace) - self._action_servers = {} + self._action_servers: Dict[str, ActionServer] = {} self._property_publishers = {} self._status_types = status_types self._action_value_mappings = action_value_mappings @@ -284,7 +285,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): self.create_ros_action_server(action_name, action_value_mapping) # 创建线程池执行器 - self._executor = ThreadPoolExecutor(max_workers=max(len(action_value_mappings), 1)) + self._executor = ThreadPoolExecutor(max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}") # 创建资源管理客户端 self._resource_clients: Dict[str, Client] = { @@ -295,6 +296,18 @@ class BaseROS2DeviceNode(Node, Generic[T]): "resource_list": self.create_client(ResourceList, "/resources/list"), } + def query_host_name_cb(req, res): + self.register_device() + self.lab_logger().info("Host要求重新注册当前节点") + res.response = "" + return res + + self._service_server: Dict[str, Service] = { + "query_host_name": self.create_service( + SerialCommand, f"/srv{self.namespace}/query_host_name", query_host_name_cb, callback_group=self.callback_group + ), + } + # 向全局在线设备注册表添加设备信息 self.register_device() rclpy.get_global_executor().add_node(self) @@ -318,6 +331,31 @@ class BaseROS2DeviceNode(Node, Generic[T]): ) # 加入全局注册表 registered_devices[self.device_id] = device_info + from unilabos.config.config import BasicConfig + if not BasicConfig.is_host_mode: + sclient = self.create_client(SerialCommand, "/node_info_update") + # 启动线程执行发送任务 + threading.Thread( + target=self.send_slave_node_info, + args=(sclient,), + daemon=True, + name=f"ROSDevice{self.device_id}_send_slave_node_info" + ).start() + + def send_slave_node_info(self, sclient): + sclient.wait_for_service() + request = SerialCommand.Request() + from unilabos.config.config import BasicConfig + request.command = json.dumps({ + "SYNC_SLAVE_NODE_INFO": { + "machine_name": BasicConfig.machine_name, + "type": "slave", + "edge_device_id": self.device_id + }}, ensure_ascii=False, cls=TypeEncoder) + + # 发送异步请求并等待结果 + future = sclient.call_async(request) + response = future.result() def lab_logger(self): """ diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 34132b35..5a739773 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -1,4 +1,5 @@ import copy +import json import threading import time import traceback @@ -7,12 +8,13 @@ from typing import Optional, Dict, Any, List, ClassVar, Set from action_msgs.msg import GoalStatus from unilabos_msgs.msg import Resource # type: ignore -from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList # type: ignore +from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, SerialCommand # type: ignore from rclpy.action import ActionClient, get_action_server_names_and_types_by_node from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.service import Service from unique_identifier_msgs.msg import UUID +from unilabos.registry.registry import lab_registry from unilabos.resources.registry import add_schema from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.msgs.message_converter import ( @@ -20,10 +22,12 @@ from unilabos.ros.msgs.message_converter import ( get_ros_type_by_msgname, convert_from_ros_msg, convert_to_ros_msg, - msg_converter_manager, ros_action_to_json_schema, + msg_converter_manager, + ros_action_to_json_schema, ) from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker from unilabos.ros.nodes.presets.controller_node import ControllerNode +from unilabos.utils.type_check import TypeEncoder class HostNode(BaseROS2DeviceNode): @@ -95,6 +99,7 @@ class HostNode(BaseROS2DeviceNode): # 创建设备、动作客户端和目标存储 self.devices_names: Dict[str, str] = {} # 存储设备名称和命名空间的映射 self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例 + self.device_machine_names: Dict[str, str] = {device_id: "本地", } # 存储设备ID到机器名称的映射 self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例 self._action_value_mappings: Dict[str, Dict] = ( {} @@ -106,18 +111,24 @@ class HostNode(BaseROS2DeviceNode): self._subscribed_topics = set() # 用于跟踪已订阅的话题 # 创建物料增删改查服务(非客户端) - self._init_resource_service() + self._init_host_service() self.device_status = {} # 用来存储设备状态 self.device_status_timestamps = {} # 用来存储设备状态最后更新时间 + from unilabos.app.mq import mqtt_client + for device_config in lab_registry.obtain_registry_device_info(): + mqtt_client.publish_registry(device_config["id"], device_config) + # 首次发现网络中的设备 self._discover_devices() # 初始化所有本机设备节点,多一次过滤,防止重复初始化 for device_id, device_config in devices_config.items(): if device_config.get("type", "device") != "device": - self.lab_logger().debug(f"[Host Node] Skipping type {device_config['type']} {device_id} already existed, skipping.") + self.lab_logger().debug( + f"[Host Node] Skipping type {device_config['type']} {device_id} already existed, skipping." + ) continue if device_id not in self.devices_names: self.initialize_device(device_id, device_config) @@ -150,6 +161,13 @@ class HostNode(BaseROS2DeviceNode): self.lab_logger().info("[Host Node] Host node initialized.") HostNode._ready_event.set() + def _send_re_register(self, sclient): + sclient.wait_for_service() + request = SerialCommand.Request() + request.command = "" + future = sclient.call_async(request) + response = future.result() + def _discover_devices(self) -> None: """ 发现网络中的设备 @@ -166,23 +184,37 @@ class HostNode(BaseROS2DeviceNode): current_devices = set() for device_id, namespace in nodes_and_names: - if not namespace.startswith("/devices"): + if not namespace.startswith("/devices/"): continue - + edge_device_id = namespace[9:] # 将设备添加到当前设备集合 - device_key = f"{namespace}/{device_id}" + device_key = f"{namespace}/{edge_device_id}" # namespace已经包含device_id了,这里复写一遍 current_devices.add(device_key) # 如果是新设备,记录并创建ActionClient - if device_id not in self.devices_names: + if edge_device_id not in self.devices_names: self.lab_logger().info(f"[Host Node] Discovered new device: {device_key}") - self.devices_names[device_id] = namespace + self.devices_names[edge_device_id] = namespace self._create_action_clients_for_device(device_id, namespace) self._online_devices.add(device_key) + sclient = self.create_client(SerialCommand, f"/srv{namespace}/query_host_name") + threading.Thread( + target=self._send_re_register, + args=(sclient,), + daemon=True, + name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" + ).start() elif device_key not in self._online_devices: # 设备重新上线 self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}") self._online_devices.add(device_key) + sclient = self.create_client(SerialCommand, f"/srv{namespace}/query_host_name") + threading.Thread( + target=self._send_re_register, + args=(sclient,), + daemon=True, + name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" + ).start() # 检测离线设备 offline_devices = self._online_devices - current_devices @@ -224,16 +256,22 @@ class HostNode(BaseROS2DeviceNode): self._action_clients[action_id] = ActionClient( self, action_type, action_id, callback_group=self.callback_group ) - self.lab_logger().debug(f"[Host Node] Created ActionClient: {action_id}") + self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}") + action_name = action_id[len(namespace) + 1:] + edge_device_id = namespace[9:] from unilabos.app.mq import mqtt_client info_with_schema = ros_action_to_json_schema(action_type) - mqtt_client.publish_actions(action_id, info_with_schema) + mqtt_client.publish_actions(action_name, { + "device_id": edge_device_id, + "action_name": action_name, + "schema": info_with_schema, + }) except Exception as e: self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}") def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None: """ - 根据配置初始化设备 + 根据配置初始化设备, 此函数根据提供的设备配置动态导入适当的设备类并创建其实例。 同时为设备的动作值映射设置动作客户端。 @@ -249,7 +287,8 @@ class HostNode(BaseROS2DeviceNode): if d is None: return # noinspection PyProtectedMember - self.devices_names[device_id] = d._ros_node.namespace + self.devices_names[device_id] = d._ros_node.namespace # 这里不涉及二级device_id + self.device_machine_names[device_id] = "本地" self.devices_instances[device_id] = d # noinspection PyProtectedMember for action_name, action_value_mapping in d._ros_node._action_value_mappings.items(): @@ -257,13 +296,17 @@ class HostNode(BaseROS2DeviceNode): if action_id not in self._action_clients: action_type = action_value_mapping["type"] self._action_clients[action_id] = ActionClient(self, action_type, action_id) - self.lab_logger().debug(f"[Host Node] Created ActionClient: {action_id}") + self.lab_logger().debug(f"[Host Node] Created ActionClient (Local): {action_id}") # 子设备再创建用的是Discover发现的 from unilabos.app.mq import mqtt_client info_with_schema = ros_action_to_json_schema(action_type) - mqtt_client.publish_actions(action_id, info_with_schema) + mqtt_client.publish_actions(action_name, { + "device_id": device_id, + "action_name": action_name, + "schema": info_with_schema, + }) else: self.lab_logger().warning(f"[Host Node] ActionClient {action_id} already exists.") - device_key = f"{self.devices_names[device_id]}/{device_id}" + device_key = f"{self.devices_names[device_id]}/{device_id}" # 这里不涉及二级device_id # 添加到在线设备列表 self._online_devices.add(device_key) @@ -285,8 +328,8 @@ class HostNode(BaseROS2DeviceNode): # 解析设备名和属性名 parts = topic.split("/") - if len(parts) >= 4: - device_id = parts[-2] + if len(parts) >= 4: # 可能有ProtocolNode,创建更长的设备 + device_id = "/".join(parts[2:-1]) property_name = parts[-1] # 初始化设备状态字典 @@ -473,7 +516,7 @@ class HostNode(BaseROS2DeviceNode): """Resource""" - def _init_resource_service(self): + def _init_host_service(self): self._resource_services: Dict[str, Service] = { "resource_add": self.create_service( ResourceAdd, "/resources/add", self._resource_add_callback, callback_group=ReentrantCallbackGroup() @@ -496,8 +539,39 @@ class HostNode(BaseROS2DeviceNode): "resource_list": self.create_service( ResourceList, "/resources/list", self._resource_list_callback, callback_group=ReentrantCallbackGroup() ), + "node_info_update": self.create_service( + SerialCommand, + "/node_info_update", + self._node_info_update_callback, + callback_group=ReentrantCallbackGroup(), + ), } + def _node_info_update_callback(self, request, response): + """ + 更新节点信息回调 + """ + self.lab_logger().info(f"[Host Node] Node info update request received: {request}") + try: + from unilabos.app.mq import mqtt_client + + info = json.loads(request.command) + if "SYNC_SLAVE_NODE_INFO" in info: + info = info["SYNC_SLAVE_NODE_INFO"] + machine_name = info["machine_name"] + edge_device_id = info["edge_device_id"] + self.device_machine_names[edge_device_id] = machine_name + else: + registry_config = info["registry_config"] + for device_config in registry_config: + mqtt_client.publish_registry(device_config["id"], device_config) + self.lab_logger().debug(f"[Host Node] Node info update: {info}") + response.response = "OK" + except Exception as e: + self.lab_logger().error(f"[Host Node] Error updating node info: {e.args}") + response.response = "ERROR" + return response + def _resource_add_callback(self, request, response): """ 添加资源回调 diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index b2402b5e..2ea30856 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -224,8 +224,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]): if hasattr(self.device_instance, "setup") and asyncio.iscoroutinefunction(getattr(self.device_instance, "setup")): from unilabos.ros.nodes.base_device_node import ROS2DeviceNode ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(lambda x: logger.debug(f"PyLabRobot设备实例 {self.device_instance} 设置完成")) -# 2486229810384 -#2486232539792 + class ProtocolNodeCreator(DeviceClassCreator[T]): """ From 9d034bd3437b2b89b7d255e6761fe336e35ba24b Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 1 May 2025 15:02:36 +0800 Subject: [PATCH 2/3] Dev Sync (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update README and MQTTClient for installation instructions and code improvements * feat: 支持local_config启动 add: 增加对crt path的说明,为传入config.py的相对路径 move: web component * add: registry description * feat: node_info_update srv fix: OTDeck cant create * close #12 feat: slave node registry * feat: show machine name fix: host node registry not uploaded * feat: add hplc registry * feat: add hplc registry * fix: hplc status typo * fix: devices/ * fix: device.class possible null * fix: HPLC additions with online service * fix: slave mode spin not working * fix: slave mode spin not working * feat: 多ProtocolNode 允许子设备ID相同 feat: 上报发现的ActionClient feat: Host重启动,通过discover机制要求slaveNode重新注册,实现信息及时上报 * feat: 支持env设置config --------- Co-authored-by: Harvey Que --- unilabos/app/main.py | 6 ++-- unilabos/config/config.py | 61 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/unilabos/app/main.py b/unilabos/app/main.py index f75a3295..89462822 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -12,7 +12,7 @@ ilabos_dir = os.path.dirname(os.path.dirname(current_dir)) if ilabos_dir not in sys.path: sys.path.append(ilabos_dir) -from unilabos.config.config import load_config, BasicConfig +from unilabos.config.config import load_config, BasicConfig, _update_config_from_env from unilabos.utils.banner_print import print_status, print_unilab_banner @@ -80,8 +80,10 @@ def main(): args = parse_args() args_dict = vars(args) - # 加载配置文件 - 这里保持最先加载配置的逻辑 + # 加载配置文件,优先加载config,然后从env读取 config_path = args_dict.get("config") + if config_path is None: + config_path = os.environ.get("UNILABOS.BASICCONFIG.CONFIG_PATH", None) if config_path: if not os.path.exists(config_path): print_status(f"配置文件 {config_path} 不存在", "error") diff --git a/unilabos/config/config.py b/unilabos/config/config.py index 3f3d8dd7..12ed4f6e 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -101,26 +101,79 @@ def _update_config_from_module(module): logger.warning("Skipping key file loading, key_file is empty") +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 + class_field = key_path.upper().split(".", 1) + if len(class_field) != 2: + logger.warning(f"[ENV] 环境变量格式不正确:{env_key}") + continue + + class_key, field_key = class_field + # 遍历 globals 找匹配类(不区分大小写) + matched_cls = None + for name, obj in globals().items(): + if name.upper() == class_key and isinstance(obj, type): + matched_cls = obj + break + + if matched_cls is None: + logger.warning(f"[ENV] 未找到类:{class_key}") + continue + + # 查找类属性(不区分大小写) + matched_field = None + for attr in dir(matched_cls): + if attr.upper() == field_key: + matched_field = attr + break + + if matched_field is None: + logger.warning(f"[ENV] 类 {matched_cls.__name__} 中未找到字段:{field_key}") + continue + + current_value = getattr(matched_cls, matched_field) + attr_type = type(current_value) + if attr_type == bool: + value = env_value.lower() in ("true", "1", "yes") + elif attr_type == int: + value = int(env_value) + elif attr_type == float: + value = float(env_value) + else: + value = env_value + setattr(matched_cls, matched_field, value) + logger.info(f"[ENV] 设置 {matched_cls.__name__}.{matched_field} = {value}") + except Exception as e: + logger.warning(f"[ENV] 解析环境变量 {env_key} 失败: {e}") + + + def load_config(config_path=None): # 如果提供了配置文件路径,从该文件导入配置 if config_path: + _update_config_from_env() # 允许config_path被env设定后读取 BasicConfig.config_path = os.path.abspath(os.path.dirname(config_path)) if not os.path.exists(config_path): - logger.error(f"配置文件 {config_path} 不存在") + logger.error(f"[ENV] 配置文件 {config_path} 不存在") exit(1) try: module_name = "lab_" + os.path.basename(config_path).replace(".py", "") spec = importlib.util.spec_from_file_location(module_name, config_path) if spec is None: - logger.error(f"配置文件 {config_path} 错误") + logger.error(f"[ENV] 配置文件 {config_path} 错误") return module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) # type: ignore _update_config_from_module(module) - logger.info(f"配置文件 {config_path} 加载成功") + logger.info(f"[ENV] 配置文件 {config_path} 加载成功") except Exception as e: - logger.error(f"加载配置文件 {config_path} 失败: {e}") + logger.error(f"[ENV] 加载配置文件 {config_path} 失败") traceback.print_exc() exit(1) else: From 200ebaff311d4a120a18ad4c8d456a2a0ef2f084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=BF=8A=E6=9D=B0?= <43375851+wjjxxx@users.noreply.github.com> Date: Tue, 6 May 2025 10:35:22 +0800 Subject: [PATCH 3/3] add devices: laiyu zhida (#20) add_laiyu_zhida --- unilabos/devices/laiyu_add_solid/laiyu.py | 304 ++++++++++++++++++ .../devices/zhida_hplc/possible_status.txt | 15 + unilabos/devices/zhida_hplc/zhida.py | 112 +++++++ unilabos/devices/zhida_hplc/zhida_test_1.csv | 2 + .../registry/devices/laiyu_add_solid.yaml | 56 ++++ unilabos/registry/devices/zhida_hplc.yaml | 27 ++ unilabos_msgs/action/EmptyIn.action | 4 + unilabos_msgs/action/FloatSingleInput.action | 4 + unilabos_msgs/action/IntSingleInput.action | 4 + .../action/Point3DSeparateInput.action | 6 + .../action/SolidDispenseAddPowderTube.action | 7 + unilabos_msgs/action/StrSingleInput.action | 4 + 12 files changed, 545 insertions(+) create mode 100644 unilabos/devices/laiyu_add_solid/laiyu.py create mode 100644 unilabos/devices/zhida_hplc/possible_status.txt create mode 100644 unilabos/devices/zhida_hplc/zhida.py create mode 100644 unilabos/devices/zhida_hplc/zhida_test_1.csv create mode 100644 unilabos/registry/devices/laiyu_add_solid.yaml create mode 100644 unilabos/registry/devices/zhida_hplc.yaml create mode 100644 unilabos_msgs/action/EmptyIn.action create mode 100644 unilabos_msgs/action/FloatSingleInput.action create mode 100644 unilabos_msgs/action/IntSingleInput.action create mode 100644 unilabos_msgs/action/Point3DSeparateInput.action create mode 100644 unilabos_msgs/action/SolidDispenseAddPowderTube.action create mode 100644 unilabos_msgs/action/StrSingleInput.action diff --git a/unilabos/devices/laiyu_add_solid/laiyu.py b/unilabos/devices/laiyu_add_solid/laiyu.py new file mode 100644 index 00000000..0959f9a8 --- /dev/null +++ b/unilabos/devices/laiyu_add_solid/laiyu.py @@ -0,0 +1,304 @@ +import serial +import time +import pandas as pd + + +class Laiyu: + @property + def status(self) -> str: + return "" + + + def __init__(self, port, baudrate=115200, timeout=0.5): + """ + 初始化串口参数,默认波特率115200,8位数据位、1位停止位、无校验 + """ + self.ser = serial.Serial(port, baudrate=baudrate, timeout=timeout) + + def calculate_crc(self, data: bytes) -> bytes: + """ + 计算Modbus CRC-16,返回低字节和高字节(little-endian) + """ + crc = 0xFFFF + for pos in data: + crc ^= pos + for _ in range(8): + if crc & 0x0001: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc.to_bytes(2, byteorder='little') + + def send_command(self, command: bytes) -> bytes: + """ + 构造完整指令帧(加上CRC校验),发送指令后一直等待设备响应,直至响应结束或超时(最大3分钟) + """ + crc = self.calculate_crc(command) + full_command = command + crc + # 清空接收缓存 + self.ser.reset_input_buffer() + self.ser.write(full_command) + print("发送指令:", full_command.hex().upper()) # 打印发送的指令帧 + + # 持续等待响应,直到连续0.5秒没有新数据或超时(3分钟) + start_time = time.time() + last_data_time = time.time() + response = bytearray() + while True: + if self.ser.in_waiting > 0: + new_data = self.ser.read(self.ser.in_waiting) + response.extend(new_data) + last_data_time = time.time() + # 如果已有数据,并且0.5秒内无新数据,则认为响应结束 + if response and (time.time() - last_data_time) > 0.5: + break + # 超过最大等待时间,退出循环 + if time.time() - start_time > 180: + break + time.sleep(0.1) + return bytes(response) + + def pick_powder_tube(self, int_input: int) -> bytes: + """ + 拿取粉筒指令: + - 功能码06 + - 寄存器地址0x0037(取粉筒) + - 数据:粉筒编号(如1代表A,2代表B,以此类推) + 示例:拿取A粉筒指令帧:01 06 00 37 00 01 + CRC + """ + slave_addr = 0x01 + function_code = 0x06 + register_addr = 0x0037 + # 数据部分:粉筒编号转换为2字节大端 + data = int_input.to_bytes(2, byteorder='big') + command = bytes([slave_addr, function_code]) + register_addr.to_bytes(2, byteorder='big') + data + return self.send_command(command) + + def put_powder_tube(self, int_input: int) -> bytes: + """ + 放回粉筒指令: + - 功能码06 + - 寄存器地址0x0038(放回粉筒) + - 数据:粉筒编号 + 示例:放回A粉筒指令帧:01 06 00 38 00 01 + CRC + """ + slave_addr = 0x01 + function_code = 0x06 + register_addr = 0x0038 + data = int_input.to_bytes(2, byteorder='big') + command = bytes([slave_addr, function_code]) + register_addr.to_bytes(2, byteorder='big') + data + return self.send_command(command) + + def reset(self) -> bytes: + """ + 重置指令: + - 功能码 0x06 + - 寄存器地址 0x0042 (示例中用了 00 42) + - 数据 0x0001 + 示例发送:01 06 00 42 00 01 E8 1E + """ + slave_addr = 0x01 + function_code = 0x06 + register_addr = 0x0042 # 对应示例中的 00 42 + payload = (0x0001).to_bytes(2, 'big') # 重置命令 + + cmd = ( + bytes([slave_addr, function_code]) + + register_addr.to_bytes(2, 'big') + + payload + ) + return self.send_command(cmd) + + + def move_to_xyz(self, x: float, y: float, z: float) -> bytes: + """ + 移动到指定位置指令: + - 功能码10(写多个寄存器) + - 寄存器起始地址0x0030 + - 寄存器数量:3个(x,y,z) + - 字节计数:6 + - 数据:x,y,z各2字节,单位为0.1mm(例如1mm对应数值10) + 示例帧:01 10 00 30 00 03 06 00C8 02BC 02EE + CRC + """ + slave_addr = 0x01 + function_code = 0x10 + register_addr = 0x0030 + num_registers = 3 + byte_count = num_registers * 2 # 6字节 + + # 将mm转换为0.1mm单位(乘以10),转换为2字节大端表示 + x_val = int(x * 10) + y_val = int(y * 10) + z_val = int(z * 10) + data = x_val.to_bytes(2, 'big') + y_val.to_bytes(2, 'big') + z_val.to_bytes(2, 'big') + + command = (bytes([slave_addr, function_code]) + + register_addr.to_bytes(2, 'big') + + num_registers.to_bytes(2, 'big') + + byte_count.to_bytes(1, 'big') + + data) + return self.send_command(command) + + def discharge(self, float_in: float) -> bytes: + """ + 出料指令: + - 使用写多个寄存器命令(功能码 0x10) + - 寄存器起始地址设为 0x0039 + - 寄存器数量为 0x0002(两个寄存器:出料质量和误差范围) + - 字节计数为 0x04(每个寄存器2字节,共4字节) + - 数据:出料质量(单位0.1mg,例如10mg对应100,即0x0064)、误差范围固定为0x0005 + 示例发送帧:01 10 00 39 0002 04 00640005 + CRC + """ + mass = float_in + slave_addr = 0x01 + function_code = 0x10 # 修改为写多个寄存器的功能码 + start_register = 0x0039 # 寄存器起始地址 + quantity = 0x0002 # 寄存器数量 + byte_count = 0x04 # 字节数:2寄存器*2字节=4 + mass_val = int(mass * 10) # 质量转换,单位0.1mg + error_margin = 5 # 固定误差范围,0x0005 + + command = (bytes([slave_addr, function_code]) + + start_register.to_bytes(2, 'big') + + quantity.to_bytes(2, 'big') + + byte_count.to_bytes(1, 'big') + + mass_val.to_bytes(2, 'big') + + error_margin.to_bytes(2, 'big')) + return self.send_command(command) + + + ''' + 示例:这个是标智96孔板的坐标转换,但是不同96孔板的坐标可能不同 + 所以需要根据实际情况进行修改 + ''' + + def move_to_plate(self, string): + #只接受两位数的str,比如a1,a2,b1,b2 + # 解析位置字符串 + if len(string) != 2 and len(string) != 3: + raise ValueError("Invalid plate position") + if not string[0].isalpha() or not string[1:].isdigit(): + raise ValueError("Invalid plate position") + a = string[0] # 字母部分s + b = string[1:] # 数字部分 + + if a.isalpha(): + a = ord(a.lower()) - ord('a') + 1 + else: + print('1') + raise ValueError("Invalid plate position") + a = int(a) + b = int(b) + # max a = 8, max b = 12, 否则报错 + if a > 8 or b > 12: + print('2') + raise ValueError("Invalid plate position") + # 计算移动到指定位置的坐标 + # a=1, x=3.0; a=12, x=220.0 + # b=1, y=62.0; b=8, y=201.0 + # z = 110.0 + x = float((b-1) * (220-4.0)/11 + 4.0) + y = float((a-1) * (201.0-62.0)/7 + 62.0) + z = 110.0 + # 移动到指定位置 + resp_move = self.move_to_xyz(x, y, z) + print("移动位置响应:", resp_move.hex().upper()) + # 打印移动到指定位置的坐标 + print(f"移动到位置:{string},坐标:x={x:.2f}, y={y:.2f}, z={z:.2f}") + return resp_move + + def add_powder_tube(self, powder_tube_number, target_tube_position, compound_mass): + # 拿取粉筒 + resp_pick = self.pick_powder_tube(powder_tube_number) + print("拿取粉筒响应:", resp_pick.hex().upper()) + time.sleep(1) + # 移动到指定位置 + self.move_to_plate(target_tube_position) + time.sleep(1) + # 出料,设定质量 + resp_discharge = self.discharge(compound_mass) + print("出料响应:", resp_discharge.hex().upper()) + # 使用modbus协议读取实际出料质量 + # 样例 01 06 00 40 00 64 89 F5,其中 00 64 是实际出料质量,换算为十进制为100,代表10 mg + # 从resp_discharge读取实际出料质量 + # 提取字节4和字节5的两个字节 + actual_mass_raw = int.from_bytes(resp_discharge[4:6], byteorder='big') + # 根据说明,将读取到的数据转换为实际出料质量(mg),这里除以10,例如:0x0064 = 100,转换后为10 mg + actual_mass_mg = actual_mass_raw / 10 + print(f"孔位{target_tube_position},实际出料质量:{actual_mass_mg}mg") + time.sleep(1) + # 放回粉筒 + resp_put = self.put_powder_tube(powder_tube_number) + print("放回粉筒响应:", resp_put.hex().upper()) + print(f"放回粉筒{powder_tube_number}") + resp_reset = self.reset() + return actual_mass_mg + + + +''' +样例:对单个粉筒进行称量 +''' + +modbus = Laiyu(port="COM25") + +mass_test = modbus.add_powder_tube(1, 'h12', 6.0) +print(f"实际出料质量:{mass_test}mg") + + +''' +样例: 对一份excel文件记录的化合物进行称量 +''' + +excel_file = r"C:\auto\laiyu\test1.xlsx" +# 定义输出文件路径,用于记录实际加样多少 +output_file = r"C:\auto\laiyu\test_output.xlsx" + +# 定义物料名称和料筒位置关系 +compound_positions = { + 'XPhos': '1', + 'Cu(OTf)2': '2', + 'CuSO4': '3', + 'PPh3': '4', +} + +# read excel file +# excel_file = r"C:\auto\laiyu\test.xlsx" +df = pd.read_excel(excel_file, sheet_name='Sheet1') +# 读取Excel文件中的数据 +# 遍历每一行数据 +for index, row in df.iterrows(): + # 获取物料名称和质量 + copper_name = row['copper'] + copper_mass = row['copper_mass'] + ligand_name = row['ligand'] + ligand_mass = row['ligand_mass'] + target_tube_position = row['position'] + # 获取物料位置 from compound_positions + copper_position = compound_positions.get(copper_name) + ligand_position = compound_positions.get(ligand_name) + # 判断物料位置是否存在 + if copper_position is None: + print(f"物料位置不存在:{copper_name}") + continue + if ligand_position is None: + print(f"物料位置不存在:{ligand_name}") + continue + # 加铜 + copper_actual_mass = modbus.add_powder_tube(int(copper_position), target_tube_position, copper_mass) + time.sleep(1) + # 加配体 + ligand_actual_mass = modbus.add_powder_tube(int(ligand_position), target_tube_position, ligand_mass) + time.sleep(1) + # 保存至df + df.at[index, 'copper_actual_mass'] = copper_actual_mass + df.at[index, 'ligand_actual_mass'] = ligand_actual_mass + +# 保存修改后的数据到新的Excel文件 +df.to_excel(output_file, index=False) +print(f"已保存到文件:{output_file}") + +# 关闭串口 +modbus.ser.close() +print("串口已关闭") + diff --git a/unilabos/devices/zhida_hplc/possible_status.txt b/unilabos/devices/zhida_hplc/possible_status.txt new file mode 100644 index 00000000..cf4e9efb --- /dev/null +++ b/unilabos/devices/zhida_hplc/possible_status.txt @@ -0,0 +1,15 @@ +(base) PS C:\Users\dell\Desktop> python zhida.py getstatus +{ + "result": "RUN", + "message": "AcqTime:3.321049min Vial:1" +} +(base) PS C:\Users\dell\Desktop> python zhida.py getstatus +{ + "result": "NOTREADY", + "message": "AcqTime:0min Vial:1" +} +(base) PS C:\Users\dell\Desktop> python zhida.py getstatus +{ + "result": "PRERUN", + "message": "AcqTime:0min Vial:1" +} diff --git a/unilabos/devices/zhida_hplc/zhida.py b/unilabos/devices/zhida_hplc/zhida.py new file mode 100644 index 00000000..a6e1f9d3 --- /dev/null +++ b/unilabos/devices/zhida_hplc/zhida.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import socket +import json +import base64 +import argparse +import sys +import time + + +class ZhidaClient: + def __init__(self, host='192.168.1.47', port=5792, timeout=10.0): + self.host = host + self.port = port + self.timeout = timeout + self.sock = None + + def connect(self): + """建立 TCP 连接,并设置超时用于后续 recv/send。""" + self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout) + # 确保后续 recv/send 都会在 timeout 秒后抛 socket.timeout + self.sock.settimeout(self.timeout) + + def close(self): + """关闭连接。""" + if self.sock: + self.sock.close() + self.sock = None + + def _send_command(self, cmd: dict) -> dict: + """ + 发送一条命令,接收 raw bytes,直到能成功 json.loads。 + """ + if not self.sock: + raise ConnectionError("Not connected") + + # 1) 发送 JSON 命令 + payload = json.dumps(cmd, ensure_ascii=False).encode('utf-8') + # 如果服务端需要换行分隔,也可以加上: payload += b'\n' + self.sock.sendall(payload) + + # 2) 循环 recv,直到能成功解析完整 JSON + buffer = bytearray() + start = time.time() + while True: + try: + chunk = self.sock.recv(4096) + if not chunk: + # 对端关闭 + break + buffer.extend(chunk) + # 尝试解码、解析 + text = buffer.decode('utf-8', errors='strict') + try: + return json.loads(text) + except json.JSONDecodeError: + # 继续 recv + pass + except socket.timeout: + raise TimeoutError("recv() timed out after {:.1f}s".format(self.timeout)) + # 可选:防止死循环,整个循环时长超过 2×timeout 就报错 + if time.time() - start > self.timeout * 2: + raise TimeoutError("No complete JSON received after {:.1f}s".format(time.time() - start)) + + raise ConnectionError("Connection closed before JSON could be parsed") + +# @property +# def xxx() -> 类型: +# return xxxxxx + +# def send_command(self, ): +# self.xxx = dict[xxx] + +# 示例响应回复: +# { +# "result": "RUN", +# "message": "AcqTime:3.321049min Vial:1" +# } + + @property + def status(self) -> dict: + return self._send_command({"command": "getstatus"})["result"] + + # def get_status(self) -> dict: + # print(self._send_command({"command": "getstatus"})) + # return self._send_command({"command": "getstatus"}) + + def get_methods(self) -> dict: + return self._send_command({"command": "getmethods"}) + + def start(self, text) -> dict: + b64 = base64.b64encode(text.encode('utf-8')).decode('ascii') + return self._send_command({"command": "start", "message": b64}) + + def abort(self) -> dict: + return self._send_command({"command": "abort"}) + +""" +a,b,c +1,2,4 +2,4,5 +""" + +client = ZhidaClient() +# 连接 +client.connect() +# 获取状态 +print(client.status) + + +# 命令格式:python zhida.py [options] diff --git a/unilabos/devices/zhida_hplc/zhida_test_1.csv b/unilabos/devices/zhida_hplc/zhida_test_1.csv new file mode 100644 index 00000000..96cef55e --- /dev/null +++ b/unilabos/devices/zhida_hplc/zhida_test_1.csv @@ -0,0 +1,2 @@ +SampleName,AcqMethod,RackCode,VialPos,SmplInjVol,OutputFile +Sample001,1028-10ul-10min.M,CStk1-01,1,10,DataSET1 \ No newline at end of file diff --git a/unilabos/registry/devices/laiyu_add_solid.yaml b/unilabos/registry/devices/laiyu_add_solid.yaml new file mode 100644 index 00000000..35540235 --- /dev/null +++ b/unilabos/registry/devices/laiyu_add_solid.yaml @@ -0,0 +1,56 @@ +laiyu_add_solid: + description: Laiyu Add Solid + class: + module: unilabos.devices.laiyu_add_solid.laiyu:Laiyu + type: python + status_types: {} + action_value_mappings: + move_to_xyz: + type: Point3DSeparateInput + goal: + x: x + y: y + z: z + feedback: {} + result: {} + pick_powder_tube: + type: IntSingleInput + goal: + int_input: int_input + feedback: {} + result: {} + put_powder_tube: + type: IntSingleInput + goal: + int_input: int_input + feedback: {} + result: {} + reset: + type: EmptyIn + goal: {} + feedback: {} + result: {} + add_powder_tube: + type: SolidDispenseAddPowderTube + goal: + powder_tube_number: powder_tube_number + target_tube_position: target_tube_position + compound_mass: compound_mass + feedback: {} + result: + actual_mass_mg: actual_mass_mg + move_to_plate: + type: StrSingleInput + goal: + string: string + feedback: {} + result: {} + discharge: + type: FloatSingleInput + goal: + float_input: float_input + feedback: {} + result: {} + + schema: + properties: {} \ No newline at end of file diff --git a/unilabos/registry/devices/zhida_hplc.yaml b/unilabos/registry/devices/zhida_hplc.yaml new file mode 100644 index 00000000..ba121ae6 --- /dev/null +++ b/unilabos/registry/devices/zhida_hplc.yaml @@ -0,0 +1,27 @@ +zhida_hplc: + description: Zhida HPLC + class: + module: unilabos.devices.zhida_hplc.zhida:ZhidaClient + type: python + status_types: + status: String + action_value_mappings: + start: + type: StringSingleInput + goal: + string: string + feedback: {} + result: {} + abort: + type: EmptyIn + goal: {} + feedback: {} + result: {} + get_methods: + type: EmptyIn + goal: {} + feedback: {} + result: {} + + schema: + properties: {} \ No newline at end of file diff --git a/unilabos_msgs/action/EmptyIn.action b/unilabos_msgs/action/EmptyIn.action new file mode 100644 index 00000000..c44b70c0 --- /dev/null +++ b/unilabos_msgs/action/EmptyIn.action @@ -0,0 +1,4 @@ + +--- + +--- \ No newline at end of file diff --git a/unilabos_msgs/action/FloatSingleInput.action b/unilabos_msgs/action/FloatSingleInput.action new file mode 100644 index 00000000..2542d31f --- /dev/null +++ b/unilabos_msgs/action/FloatSingleInput.action @@ -0,0 +1,4 @@ +float64 float_in +--- +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/IntSingleInput.action b/unilabos_msgs/action/IntSingleInput.action new file mode 100644 index 00000000..0f8b7aaa --- /dev/null +++ b/unilabos_msgs/action/IntSingleInput.action @@ -0,0 +1,4 @@ +int32 int_input +--- +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/Point3DSeparateInput.action b/unilabos_msgs/action/Point3DSeparateInput.action new file mode 100644 index 00000000..4e15e8f8 --- /dev/null +++ b/unilabos_msgs/action/Point3DSeparateInput.action @@ -0,0 +1,6 @@ +float64 x +float64 y +float64 z +--- +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/SolidDispenseAddPowderTube.action b/unilabos_msgs/action/SolidDispenseAddPowderTube.action new file mode 100644 index 00000000..674c4ffc --- /dev/null +++ b/unilabos_msgs/action/SolidDispenseAddPowderTube.action @@ -0,0 +1,7 @@ +int32 powder_tube_number +string target_tube_position +float64 compound_mass +--- +float64 actual_mass_mg +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/StrSingleInput.action b/unilabos_msgs/action/StrSingleInput.action new file mode 100644 index 00000000..bb762a58 --- /dev/null +++ b/unilabos_msgs/action/StrSingleInput.action @@ -0,0 +1,4 @@ +string string +--- +bool success +--- \ No newline at end of file