Merge branch 'device_visualization' of https://github.com/q434343/Uni-Lab-OS into device_visualization

This commit is contained in:
zhangshixiang
2025-04-30 16:47:31 +08:00
22 changed files with 264 additions and 103 deletions

View File

@@ -5,7 +5,7 @@
"name": "HPLC", "name": "HPLC",
"parent": null, "parent": null,
"type": "device", "type": "device",
"class": "hplc", "class": "hplc.agilent",
"position": { "position": {
"x": 620.6111111111111, "x": 620.6111111111111,
"y": 171, "y": 171,

View File

@@ -59,6 +59,18 @@ def parse_args():
default=None, default=None,
help="配置文件路径,支持.py格式的Python配置文件", help="配置文件路径,支持.py格式的Python配置文件",
) )
parser.add_argument(
"--port",
type=int,
default=8002,
help="信息页web服务的启动端口",
)
parser.add_argument(
"--open_browser",
type=bool,
default=True,
help="是否在启动时打开信息页",
)
parser.add_argument( parser.add_argument(
"--visual", "--visual",
choices=["rviz", "web","None"], choices=["rviz", "web","None"],

View File

@@ -146,7 +146,7 @@ class MQTTClient:
if self.mqtt_disable: if self.mqtt_disable:
return return
status = {"data": device_status.get(device_id, {}), "device_id": device_id} 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) self.client.publish(address, json.dumps(status), qos=2)
logger.critical(f"Device status published: address: {address}, {status}") logger.critical(f"Device status published: address: {address}, {status}")

View File

@@ -92,19 +92,7 @@ def setup_web_pages(router: APIRouter) -> None:
# 获取已加载的设备 # 获取已加载的设备
if lab_registry: if lab_registry:
# 设备类型 devices = json.loads(json.dumps(lab_registry.obtain_registry_device_info(), ensure_ascii=False, cls=TypeEncoder))
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)
# 资源类型 # 资源类型
for resource_id, resource_info in lab_registry.resource_type_registry.items(): for resource_id, resource_info in lab_registry.resource_type_registry.items():
resources.append( resources.append(

View File

@@ -96,17 +96,19 @@
<tr> <tr>
<th>设备ID</th> <th>设备ID</th>
<th>命名空间</th> <th>命名空间</th>
<th>机器名称</th>
<th>状态</th> <th>状态</th>
</tr> </tr>
{% for device_id, device_info in host_node_info.devices.items() %} {% for device_id, device_info in host_node_info.devices.items() %}
<tr> <tr>
<td>{{ device_id }}</td> <td>{{ device_id }}</td>
<td>{{ device_info.namespace }}</td> <td>{{ device_info.namespace }}</td>
<td>{{ device_info.machine_name }}</td>
<td><span class="status-badge online">{{ "在线" if device_info.is_online else "离线" }}</span></td> <td><span class="status-badge online">{{ "在线" if device_info.is_online else "离线" }}</span></td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="3" class="empty-state">没有发现已管理的设备</td> <td colspan="4" class="empty-state">没有发现已管理的设备</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
@@ -218,6 +220,7 @@
<th>Device ID</th> <th>Device ID</th>
<th>节点名称</th> <th>节点名称</th>
<th>命名空间</th> <th>命名空间</th>
<th>机器名称</th>
<th>状态项</th> <th>状态项</th>
<th>动作数</th> <th>动作数</th>
</tr> </tr>
@@ -227,6 +230,7 @@
<td>{{ device_id }}</td> <td>{{ device_id }}</td>
<td>{{ device_info.node_name }}</td> <td>{{ device_info.node_name }}</td>
<td>{{ device_info.namespace }}</td> <td>{{ device_info.namespace }}</td>
<td>{{ device_info.machine_name|default("本地") }}</td>
<td>{{ ros_node_info.device_topics.get(device_id, {})|length }}</td> <td>{{ ros_node_info.device_topics.get(device_id, {})|length }}</td>
<td>{{ ros_node_info.device_actions.get(device_id, {})|length }} <span class="toggle-indicator"></span></td> <td>{{ ros_node_info.device_actions.get(device_id, {})|length }} <span class="toggle-indicator"></span></td>
</tr> </tr>
@@ -329,7 +333,7 @@
<tr id="device-info-{{ loop.index }}" class="detail-row" style="display: none;"> <tr id="device-info-{{ loop.index }}" class="detail-row" style="display: none;">
<td colspan="5"> <td colspan="5">
<div class="content-full"> <div class="content-full">
<pre>{{ device.class_json }}</pre> <pre>{{ device.class|tojson(indent=4) }}</pre>
{% if device.is_online %} {% if device.is_online %}
<div class="status-badge"><span class="online-status">在线</span></div> <div class="status-badge"><span class="online-status">在线</span></div>

View File

@@ -34,6 +34,7 @@ def get_host_node_info() -> Dict[str, Any]:
"namespace": namespace, "namespace": namespace,
"is_online": f"{namespace}/{device_id}" in host_node._online_devices, "is_online": f"{namespace}/{device_id}" in host_node._online_devices,
"key": f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}", "key": f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}",
"machine_name": host_node.device_machine_names.get(device_id, "未知"),
} }
for device_id, namespace in host_node.devices_names.items() for device_id, namespace in host_node.devices_names.items()
} }
@@ -41,9 +42,7 @@ def get_host_node_info() -> Dict[str, Any]:
host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics)) host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics))
# 获取动作客户端信息 # 获取动作客户端信息
for action_id, client in host_node._action_clients.items(): for action_id, client in host_node._action_clients.items():
host_info["action_clients"] = { host_info["action_clients"] = {action_id: get_action_info(client, full_name=action_id)}
action_id: get_action_info(client, full_name=action_id)
}
# 获取设备状态 # 获取设备状态
host_info["device_status"] = host_node.device_status host_info["device_status"] = host_node.device_status

View File

@@ -12,6 +12,7 @@ from unilabos.app.web.utils.action_utils import get_action_info
# 存储 ROS 节点信息的全局变量 # 存储 ROS 节点信息的全局变量
ros_node_info = {"online_devices": {}, "device_topics": {}, "device_actions": {}} ros_node_info = {"online_devices": {}, "device_topics": {}, "device_actions": {}}
def get_ros_node_info() -> Dict[str, Any]: def get_ros_node_info() -> Dict[str, Any]:
"""获取 ROS 节点信息,包括设备节点、发布的状态和动作 """获取 ROS 节点信息,包括设备节点、发布的状态和动作
@@ -35,6 +36,13 @@ def update_ros_node_info() -> Dict[str, Any]:
try: try:
from unilabos.ros.nodes.base_device_node import registered_devices 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(): 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"], "node_name": device_info["node_name"],
"namespace": device_info["namespace"], "namespace": device_info["namespace"],
"uuid": device_info["uuid"], "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] = { result["device_actions"][device_id] = {k: get_action_info(v, k) for k, v in device_info["actions"].items()}
k: get_action_info(v, k)
for k, v in device_info["actions"].items()
}
# 更新全局变量 # 更新全局变量
ros_node_info = result ros_node_info = result
except Exception as e: except Exception as e:

View File

@@ -6,7 +6,7 @@ def generate_clean_protocol(
G: nx.DiGraph, G: nx.DiGraph,
vessel: str, # Vessel to clean. vessel: str, # Vessel to clean.
solvent: str, # Solvent to clean vessel with. solvent: str, # Solvent to clean vessel with.
volume: float = 25000.0, # Optional. Volume of solvent to clean vessel with. volume: float = 25.0, # Optional. Volume of solvent to clean vessel with.
temp: float = 25, # Optional. Temperature to heat vessel to while cleaning. temp: float = 25, # Optional. Temperature to heat vessel to while cleaning.
repeats: int = 1, # Optional. Number of cleaning cycles to perform. repeats: int = 1, # Optional. Number of cleaning cycles to perform.
) -> list[dict]: ) -> list[dict]:
@@ -27,7 +27,7 @@ def generate_clean_protocol(
from_vessel = f"flask_{solvent}" from_vessel = f"flask_{solvent}"
waste_vessel = f"waste_workup" waste_vessel = f"waste_workup"
transfer_flowrate = flowrate = 2500.0 transfer_flowrate = flowrate = 2.5
# 生成泵操作的动作序列 # 生成泵操作的动作序列
for i in range(repeats): for i in range(repeats):

View File

@@ -24,8 +24,8 @@ def generate_evaporate_protocol(
# 生成泵操作的动作序列 # 生成泵操作的动作序列
pump_action_sequence = [] pump_action_sequence = []
reactor_volume = 500000.0 reactor_volume = 500.0
transfer_flowrate = flowrate = 2500.0 transfer_flowrate = flowrate = 2.5
# 开启冷凝器 # 开启冷凝器
pump_action_sequence.append({ pump_action_sequence.append({

View File

@@ -7,7 +7,7 @@ def generate_pump_protocol(
from_vessel: str, from_vessel: str,
to_vessel: str, to_vessel: str,
volume: float, volume: float,
flowrate: float = 500.0, flowrate: float = 0.5,
transfer_flowrate: float = 0, transfer_flowrate: float = 0,
) -> list[dict]: ) -> list[dict]:
""" """
@@ -141,11 +141,11 @@ def generate_pump_protocol_with_rinsing(
time: float = 0, time: float = 0,
viscous: bool = False, viscous: bool = False,
rinsing_solvent: str = "air", rinsing_solvent: str = "air",
rinsing_volume: float = 5000.0, rinsing_volume: float = 5.0,
rinsing_repeats: int = 2, rinsing_repeats: int = 2,
solid: bool = False, solid: bool = False,
flowrate: float = 2500.0, flowrate: float = 2.5,
transfer_flowrate: float = 500.0, transfer_flowrate: float = 0.5,
) -> list[dict]: ) -> list[dict]:
""" """
Generates a pump protocol for transferring a specified volume between vessels, including rinsing steps with a chosen solvent. This function constructs a sequence of pump actions based on the provided parameters and the shortest path in a directed graph. Generates a pump protocol for transferring a specified volume between vessels, including rinsing steps with a chosen solvent. This function constructs a sequence of pump actions based on the provided parameters and the shortest path in a directed graph.
@@ -159,11 +159,11 @@ def generate_pump_protocol_with_rinsing(
time (float, optional): Time over which to perform the transfer (default is 0). time (float, optional): Time over which to perform the transfer (default is 0).
viscous (bool, optional): Indicates if the fluid is viscous (default is False). viscous (bool, optional): Indicates if the fluid is viscous (default is False).
rinsing_solvent (str, optional): The solvent to use for rinsing (default is "air"). rinsing_solvent (str, optional): The solvent to use for rinsing (default is "air").
rinsing_volume (float, optional): The volume of rinsing solvent to use (default is 5000.0). rinsing_volume (float, optional): The volume of rinsing solvent to use (default is 5.0).
rinsing_repeats (int, optional): The number of times to repeat rinsing (default is 2). rinsing_repeats (int, optional): The number of times to repeat rinsing (default is 2).
solid (bool, optional): Indicates if the transfer involves a solid (default is False). solid (bool, optional): Indicates if the transfer involves a solid (default is False).
flowrate (float, optional): The flow rate for the transfer (default is 2500.0). 最终注入容器B时的流速 flowrate (float, optional): The flow rate for the transfer (default is 2.5). 最终注入容器B时的流速
transfer_flowrate (float, optional): The flow rate for the transfer action (default is 500.0). 泵骨架中转移流速(若不指定,默认与注入流速相同) transfer_flowrate (float, optional): The flow rate for the transfer action (default is 0.5). 泵骨架中转移流速(若不指定,默认与注入流速相同)
Returns: Returns:
list[dict]: A sequence of pump actions to be executed for the transfer and rinsing process. 泵操作的动作序列. list[dict]: A sequence of pump actions to be executed for the transfer and rinsing process. 泵操作的动作序列.
@@ -172,7 +172,7 @@ def generate_pump_protocol_with_rinsing(
AssertionError: If the number of rinsing solvents does not match the number of rinsing repeats. AssertionError: If the number of rinsing solvents does not match the number of rinsing repeats.
Examples: Examples:
pump_protocol = generate_pump_protocol_with_rinsing(G, "vessel_A", "vessel_B", 100.0, rinsing_solvent="water") pump_protocol = generate_pump_protocol_with_rinsing(G, "vessel_A", "vessel_B", 0.1, rinsing_solvent="water")
""" """
air_vessel = "flask_air" air_vessel = "flask_air"
waste_vessel = f"waste_workup" waste_vessel = f"waste_workup"

View File

@@ -11,7 +11,7 @@ def generate_separate_protocol(
to_vessel: str, # Vessel to send product phase to. to_vessel: str, # Vessel to send product phase to.
waste_phase_to_vessel: str, # Optional. Vessel to send waste phase to. waste_phase_to_vessel: str, # Optional. Vessel to send waste phase to.
solvent: str, # Optional. Solvent to add to separation vessel after contents of from_vessel has been transferred to create two phases. solvent: str, # Optional. Solvent to add to separation vessel after contents of from_vessel has been transferred to create two phases.
solvent_volume: float = 50000, # Optional. Volume of solvent to add. solvent_volume: float = 50, # Optional. Volume of solvent to add (mL).
through: str = "", # Optional. Solid chemical to send product phase through on way to to_vessel, e.g. 'celite'. through: str = "", # Optional. Solid chemical to send product phase through on way to to_vessel, e.g. 'celite'.
repeats: int = 1, # Optional. Number of separations to perform. repeats: int = 1, # Optional. Number of separations to perform.
stir_time: float = 30, # Optional. Time stir for after adding solvent, before separation of phases. stir_time: float = 30, # Optional. Time stir for after adding solvent, before separation of phases.
@@ -32,7 +32,7 @@ def generate_separate_protocol(
# 生成泵操作的动作序列 # 生成泵操作的动作序列
pump_action_sequence = [] pump_action_sequence = []
reactor_volume = 500000.0 reactor_volume = 500.0
waste_vessel = waste_phase_to_vessel waste_vessel = waste_phase_to_vessel
# TODO通过物料管理系统找到溶剂的容器 # TODO通过物料管理系统找到溶剂的容器
@@ -46,7 +46,7 @@ def generate_separate_protocol(
separator_controller = f"{separation_vessel}_controller" separator_controller = f"{separation_vessel}_controller"
separation_vessel_bottom = f"flask_{separation_vessel}" separation_vessel_bottom = f"flask_{separation_vessel}"
transfer_flowrate = flowrate = 2500.0 transfer_flowrate = flowrate = 2.5
if from_vessel != separation_vessel: if from_vessel != separation_vessel:
pump_action_sequence.append( pump_action_sequence.append(
@@ -140,8 +140,8 @@ def generate_separate_protocol(
"action_kwargs": { "action_kwargs": {
"from_vessel": separation_vessel_bottom, "from_vessel": separation_vessel_bottom,
"to_vessel": to_vessel, "to_vessel": to_vessel,
"volume": 250000.0, "volume": 250.0,
"time": 250000.0 / flowrate, "time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate, # "transfer_flowrate": transfer_flowrate,
} }
} }
@@ -164,8 +164,8 @@ def generate_separate_protocol(
"action_kwargs": { "action_kwargs": {
"from_vessel": separation_vessel_bottom, "from_vessel": separation_vessel_bottom,
"to_vessel": waste_vessel, "to_vessel": waste_vessel,
"volume": 250000.0, "volume": 250.0,
"time": 250000.0 / flowrate, "time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate, # "transfer_flowrate": transfer_flowrate,
} }
} }
@@ -179,8 +179,8 @@ def generate_separate_protocol(
"action_kwargs": { "action_kwargs": {
"from_vessel": separation_vessel_bottom, "from_vessel": separation_vessel_bottom,
"to_vessel": waste_vessel, "to_vessel": waste_vessel,
"volume": 250000.0, "volume": 250.0,
"time": 250000.0 / flowrate, "time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate, # "transfer_flowrate": transfer_flowrate,
} }
} }
@@ -203,8 +203,8 @@ def generate_separate_protocol(
"action_kwargs": { "action_kwargs": {
"from_vessel": separation_vessel_bottom, "from_vessel": separation_vessel_bottom,
"to_vessel": to_vessel, "to_vessel": to_vessel,
"volume": 250000.0, "volume": 250.0,
"time": 250000.0 / flowrate, "time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate, # "transfer_flowrate": transfer_flowrate,
} }
} }
@@ -221,8 +221,8 @@ def generate_separate_protocol(
"action_kwargs": { "action_kwargs": {
"from_vessel": to_vessel, "from_vessel": to_vessel,
"to_vessel": separation_vessel, "to_vessel": separation_vessel,
"volume": 250000.0, "volume": 250.0,
"time": 250000.0 / flowrate, "time": 250.0 / flowrate,
# "transfer_flowrate": transfer_flowrate, # "transfer_flowrate": transfer_flowrate,
} }
} }

View File

@@ -1,7 +1,7 @@
serial: serial:
description: Serial communication interface, used when sharing same serial port for multiple devices description: Serial communication interface, used when sharing same serial port for multiple devices
class: class:
module: unilabos.ros.nodes.presets:ROS2SerialNode module: unilabos.ros.nodes.presets.serial_node:ROS2SerialNode
type: ros2 type: ros2
schema: schema:
properties: {} properties: {}

View File

@@ -19,6 +19,49 @@ raman_home_made:
status: status:
type: string type: string
required: required:
- status - status
additionalProperties: false additionalProperties: false
type: object 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

View File

@@ -1,7 +1,7 @@
separator.homemade: separator.homemade:
description: Separator device with homemade grbl controller description: Separator device with homemade grbl controller
class: class:
module: unilabos.devices.separator.homemade_grbl_conductivity:Separator_Controller module: unilabos.devices.separator.homemade_grbl_conductivity:SeparatorController
type: python type: python
status_types: status_types:
sensordata: Float64 sensordata: Float64

View File

@@ -3,6 +3,10 @@ syringe_pump_with_valve.runze:
class: class:
module: unilabos.devices.pump_and_valve.runze_backbone:RunzeSyringePump module: unilabos.devices.pump_and_valve.runze_backbone:RunzeSyringePump
type: python type: python
hardware_interface:
name: hardware_interface
read: send_command
write: send_command
schema: schema:
type: object type: object
properties: properties:

View File

@@ -1,3 +1,4 @@
import json
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -6,8 +7,9 @@ from typing import Any
import yaml import yaml
from unilabos.utils import logger 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.decorator import singleton
from unilabos.utils.type_check import TypeEncoder
DEFAULT_PATHS = [Path(__file__).absolute().parent] DEFAULT_PATHS = [Path(__file__).absolute().parent]
@@ -129,6 +131,7 @@ class Registry:
action_config["type"] = self._replace_type_with_class( action_config["type"] = self._replace_type_with_class(
action_config["type"], device_id, f"动作 {action_name}" 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) 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()}" 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() lab_registry = Registry()

View File

@@ -1,4 +1,5 @@
import importlib import importlib
import inspect
import json import json
from typing import Union from typing import Union
import numpy as np 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) d = resource_ulab_to_plr_inner(resource)
"""无法通过Resource进行反序列化例如TipSpot必须内部序列化好直接用TipSpot序列化会多参数导致出错""" """无法通过Resource进行反序列化例如TipSpot必须内部序列化好直接用TipSpot序列化会多参数导致出错"""
from pylabrobot.utils.object_parsing import find_subclass 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) resource_plr.load_all_state(all_states)
return resource_plr return resource_plr

View File

@@ -1,14 +1,17 @@
import copy
import json
import os import os
import traceback import traceback
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
import rclpy import rclpy
from unilabos_msgs.msg import Resource # type: ignore 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.executors import MultiThreadedExecutor
from rclpy.node import Node from rclpy.node import Node
from rclpy.timer import Timer 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.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg, convert_to_ros_msg,
@@ -17,6 +20,7 @@ from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.ros.x.rclpyx import run_event_loop_in_thread from unilabos.ros.x.rclpyx import run_event_loop_in_thread
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.config.config import BasicConfig from unilabos.config.config import BasicConfig
from unilabos.utils.type_check import TypeEncoder
def exit() -> None: def exit() -> None:
@@ -82,7 +86,7 @@ def slave(
"""从节点函数""" """从节点函数"""
rclpy.init(args=args) rclpy.init(args=args)
rclpy.__executor = executor = MultiThreadedExecutor() rclpy.__executor = executor = MultiThreadedExecutor()
devices_config_copy = copy.deepcopy(devices_config)
for device_id, device_config in devices_config.items(): for device_id, device_config in devices_config.items():
d = initialize_device_from_dict(device_id, device_config) d = initialize_device_from_dict(device_id, device_config)
if d is None: if d is None:
@@ -98,17 +102,28 @@ def slave(
n = Node(f"slaveMachine_{machine_name}", parameter_overrides=[]) n = Node(f"slaveMachine_{machine_name}", parameter_overrides=[])
executor.add_node(n) executor.add_node(n)
if BasicConfig.slave_no_host: if not BasicConfig.slave_no_host:
# 确保ResourceAdd存在 sclient = n.create_client(SerialCommand, "/node_info_update")
if "ResourceAdd" in globals(): sclient.wait_for_service()
rclient = n.create_client(ResourceAdd, "/resources/add")
rclient.wait_for_service() # FIXME 可能一直等待,加一个参数 request = SerialCommand.Request()
request.command = json.dumps({
"machine_name": 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.")
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.")
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")
run_event_loop_in_thread() run_event_loop_in_thread()

View File

@@ -342,9 +342,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else: else:
return getattr(self.driver_instance, attr_name) return getattr(self.driver_instance, attr_name)
except AttributeError as ex: except AttributeError as ex:
self.lab_logger().error( if ex.args[0].startswith(f"AttributeError: '{self.driver_instance.__class__.__name__}' object"):
f"publish error, {str(type(self.driver_instance))[8:-2]} has no attribute '{attr_name}'" self.lab_logger().error(
) f"publish error, {str(type(self.driver_instance))[8:-2]} has no attribute '{attr_name}'"
)
else:
self.lab_logger().error(
f"publish error, when {str(type(self.driver_instance))[8:-2]} getting attribute '{attr_name}'"
)
self.lab_logger().error(traceback.format_exc())
self._property_publishers[attr_name] = PropertyPublisher( self._property_publishers[attr_name] = PropertyPublisher(
self, attr_name, get_device_attr, msg_type, initial_period, self._print_publish self, attr_name, get_device_attr, msg_type, initial_period, self._print_publish
@@ -430,6 +436,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().info(f"同步执行动作 {ACTION}") self.lab_logger().info(f"同步执行动作 {ACTION}")
future = self._executor.submit(ACTION, **action_kwargs) future = self._executor.submit(ACTION, **action_kwargs)
def _handle_future_exception(fut):
try:
fut.result()
except Exception as e:
error(f"同步任务 {ACTION.__name__} 报错了")
error(traceback.format_exc())
future.add_done_callback(_handle_future_exception)
action_type = action_value_mapping["type"] action_type = action_value_mapping["type"]
feedback_msg_types = action_type.Feedback.get_fields_and_field_types() feedback_msg_types = action_type.Feedback.get_fields_and_field_types()
result_msg_types = action_type.Result.get_fields_and_field_types() result_msg_types = action_type.Result.get_fields_and_field_types()

View File

@@ -1,17 +1,20 @@
import copy import copy
import json
import threading import threading
import time import time
import traceback
import uuid import uuid
from typing import Optional, Dict, Any, List, ClassVar, Set from typing import Optional, Dict, Any, List, ClassVar, Set
from action_msgs.msg import GoalStatus from action_msgs.msg import GoalStatus
from unilabos_msgs.msg import Resource # type: ignore 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.action import ActionClient, get_action_server_names_and_types_by_node
from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.service import Service from rclpy.service import Service
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.registry import lab_registry
from unilabos.resources.registry import add_schema from unilabos.resources.registry import add_schema
from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
@@ -19,7 +22,8 @@ from unilabos.ros.msgs.message_converter import (
get_ros_type_by_msgname, get_ros_type_by_msgname,
convert_from_ros_msg, convert_from_ros_msg,
convert_to_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.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
from unilabos.ros.nodes.presets.controller_node import ControllerNode from unilabos.ros.nodes.presets.controller_node import ControllerNode
@@ -94,6 +98,7 @@ class HostNode(BaseROS2DeviceNode):
# 创建设备、动作客户端和目标存储 # 创建设备、动作客户端和目标存储
self.devices_names: Dict[str, str] = {} # 存储设备名称和命名空间的映射 self.devices_names: Dict[str, str] = {} # 存储设备名称和命名空间的映射
self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例 self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例
self.device_machine_names: Dict[str, str] = {} # 存储设备ID到机器名称的映射
self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例 self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = ( self._action_value_mappings: Dict[str, Dict] = (
{} {}
@@ -105,18 +110,24 @@ class HostNode(BaseROS2DeviceNode):
self._subscribed_topics = set() # 用于跟踪已订阅的话题 self._subscribed_topics = set() # 用于跟踪已订阅的话题
# 创建物料增删改查服务(非客户端) # 创建物料增删改查服务(非客户端)
self._init_resource_service() self._init_host_service()
self.device_status = {} # 用来存储设备状态 self.device_status = {} # 用来存储设备状态
self.device_status_timestamps = {} # 用来存储设备状态最后更新时间 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() self._discover_devices()
# 初始化所有本机设备节点,多一次过滤,防止重复初始化 # 初始化所有本机设备节点,多一次过滤,防止重复初始化
for device_id, device_config in devices_config.items(): for device_id, device_config in devices_config.items():
if device_config.get("type", "device") != "device": 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 continue
if device_id not in self.devices_names: if device_id not in self.devices_names:
self.initialize_device(device_id, device_config) self.initialize_device(device_id, device_config)
@@ -132,10 +143,14 @@ class HostNode(BaseROS2DeviceNode):
controller_config["update_rate"] = update_rate controller_config["update_rate"] = update_rate
self.initialize_controller(controller_id, controller_config) self.initialize_controller(controller_id, controller_config)
for bridge in self.bridges: try:
if hasattr(bridge, "resource_add"): for bridge in self.bridges:
self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.") if hasattr(bridge, "resource_add"):
bridge.resource_add(add_schema(resources_config)) self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.")
bridge.resource_add(add_schema(resources_config))
except Exception as ex:
self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
self.lab_logger().error(traceback.format_exc())
# 创建定时器,定期发现设备 # 创建定时器,定期发现设备
self._discovery_timer = self.create_timer( self._discovery_timer = self.create_timer(
@@ -221,6 +236,7 @@ class HostNode(BaseROS2DeviceNode):
) )
self.lab_logger().debug(f"[Host Node] Created ActionClient: {action_id}") self.lab_logger().debug(f"[Host Node] Created ActionClient: {action_id}")
from unilabos.app.mq import mqtt_client from unilabos.app.mq import mqtt_client
info_with_schema = ros_action_to_json_schema(action_type) info_with_schema = ros_action_to_json_schema(action_type)
mqtt_client.publish_actions(action_id, info_with_schema) mqtt_client.publish_actions(action_id, info_with_schema)
except Exception as e: except Exception as e:
@@ -245,6 +261,7 @@ class HostNode(BaseROS2DeviceNode):
return return
# noinspection PyProtectedMember # noinspection PyProtectedMember
self.devices_names[device_id] = d._ros_node.namespace self.devices_names[device_id] = d._ros_node.namespace
self.device_machine_names[device_id] = "本地"
self.devices_instances[device_id] = d self.devices_instances[device_id] = d
# noinspection PyProtectedMember # noinspection PyProtectedMember
for action_name, action_value_mapping in d._ros_node._action_value_mappings.items(): for action_name, action_value_mapping in d._ros_node._action_value_mappings.items():
@@ -254,6 +271,7 @@ class HostNode(BaseROS2DeviceNode):
self._action_clients[action_id] = ActionClient(self, action_type, action_id) 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: {action_id}")
from unilabos.app.mq import mqtt_client from unilabos.app.mq import mqtt_client
info_with_schema = ros_action_to_json_schema(action_type) info_with_schema = ros_action_to_json_schema(action_type)
mqtt_client.publish_actions(action_id, info_with_schema) mqtt_client.publish_actions(action_id, info_with_schema)
else: else:
@@ -468,7 +486,7 @@ class HostNode(BaseROS2DeviceNode):
"""Resource""" """Resource"""
def _init_resource_service(self): def _init_host_service(self):
self._resource_services: Dict[str, Service] = { self._resource_services: Dict[str, Service] = {
"resource_add": self.create_service( "resource_add": self.create_service(
ResourceAdd, "/resources/add", self._resource_add_callback, callback_group=ReentrantCallbackGroup() ResourceAdd, "/resources/add", self._resource_add_callback, callback_group=ReentrantCallbackGroup()
@@ -491,8 +509,41 @@ class HostNode(BaseROS2DeviceNode):
"resource_list": self.create_service( "resource_list": self.create_service(
ResourceList, "/resources/list", self._resource_list_callback, callback_group=ReentrantCallbackGroup() 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)
machine_name = info["machine_name"]
devices_config = info["devices_config"]
registry_config = info["registry_config"]
# 更新设备机器名称映射
for device_id in devices_config.keys():
self.device_machine_names[device_id] = machine_name
self.lab_logger().debug(f"[Host Node] Updated machine name for device {device_id}: {machine_name}")
for device_config in registry_config:
mqtt_client.publish_registry(device_config["id"], device_config)
self.lab_logger().info(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: {str(e)}")
response.response = "ERROR"
return response
def _resource_add_callback(self, request, response): def _resource_add_callback(self, request, response):
""" """
添加资源回调 添加资源回调

View File

@@ -20,7 +20,7 @@ from unilabos.ros.msgs.message_converter import (
convert_from_ros_msg, convert_from_ros_msg,
convert_from_ros_msg_with_mapping, convert_from_ros_msg_with_mapping,
) )
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode
class ROS2ProtocolNode(BaseROS2DeviceNode): class ROS2ProtocolNode(BaseROS2DeviceNode):
@@ -55,34 +55,39 @@ class ROS2ProtocolNode(BaseROS2DeviceNode):
) )
# 初始化子设备 # 初始化子设备
communication_node_id = None self.communication_node_id_to_instance = {}
for device_id, device_config in self.children.items(): for device_id, device_config in self.children.items():
if device_config.get("type", "device") != "device": if device_config.get("type", "device") != "device":
self.lab_logger().debug(f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping.") self.lab_logger().debug(f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping.")
continue continue
d = self.initialize_device(device_id, device_config) try:
d = self.initialize_device(device_id, device_config)
except Exception as ex:
self.lab_logger().error(f"[Protocol Node] Failed to initialize device {device_id}: {ex}")
d = None
if d is None: if d is None:
continue continue
if "serial_" in device_id or "io_" in device_id: if "serial_" in device_id or "io_" in device_id:
communication_node_id = device_id self.communication_node_id_to_instance[device_id] = d
continue continue
# 设置硬件接口代理 # 设置硬件接口代理
if d and hasattr(d, "_hardware_interface"): if d:
if ( if (
hasattr(d, d._hardware_interface["name"]) hasattr(d.driver_instance, d.ros_node_instance._hardware_interface["name"])
and hasattr(d, d._hardware_interface["write"]) and hasattr(d.driver_instance, d.ros_node_instance._hardware_interface["write"])
and (d._hardware_interface["read"] is None or hasattr(d, d._hardware_interface["read"])) and (d.ros_node_instance._hardware_interface["read"] is None or hasattr(d.driver_instance, d.ros_node_instance._hardware_interface["read"]))
): ):
name = getattr(d, d._hardware_interface["name"]) name = getattr(d.driver_instance, d.ros_node_instance._hardware_interface["name"])
read = d._hardware_interface.get("read", None) read = d.ros_node_instance._hardware_interface.get("read", None)
write = d._hardware_interface.get("write", None) write = d.ros_node_instance._hardware_interface.get("write", None)
# 如果硬件接口是字符串,通过通信设备提供 # 如果硬件接口是字符串,通过通信设备提供
if isinstance(name, str) and communication_node_id in self.sub_devices: if isinstance(name, str) and name in self.sub_devices:
self._setup_hardware_proxy(d, self.sub_devices[communication_node_id], read, write) self._setup_hardware_proxy(d, self.sub_devices[name], read, write)
def _setup_protocol_names(self, protocol_type): def _setup_protocol_names(self, protocol_type):
# 处理协议类型 # 处理协议类型
@@ -234,11 +239,11 @@ class ROS2ProtocolNode(BaseROS2DeviceNode):
"""还没有改过的部分""" """还没有改过的部分"""
def _setup_hardware_proxy(self, device, communication_device, read_method, write_method): def _setup_hardware_proxy(self, device: ROS2DeviceNode, communication_device: ROS2DeviceNode, read_method, write_method):
"""为设备设置硬件接口代理""" """为设备设置硬件接口代理"""
extra_info = [getattr(device, info) for info in communication_device._hardware_interface.get("extra_info", [])] extra_info = [getattr(device.driver_instance, info) for info in communication_device.ros_node_instance._hardware_interface.get("extra_info", [])]
write_func = getattr(communication_device, communication_device._hardware_interface["write"]) write_func = getattr(communication_device.ros_node_instance, communication_device.ros_node_instance._hardware_interface["write"])
read_func = getattr(communication_device, communication_device._hardware_interface["read"]) read_func = getattr(communication_device.ros_node_instance, communication_device.ros_node_instance._hardware_interface["read"])
def _read(): def _read():
return read_func(*extra_info) return read_func(*extra_info)
@@ -247,9 +252,9 @@ class ROS2ProtocolNode(BaseROS2DeviceNode):
return write_func(*extra_info, command) return write_func(*extra_info, command)
if read_method: if read_method:
setattr(device, read_method, _read) setattr(device.driver_instance, read_method, _read)
if write_method: if write_method:
setattr(device, write_method, _write) setattr(device.driver_instance, write_method, _write)
async def _update_resources(self, goal, protocol_kwargs): async def _update_resources(self, goal, protocol_kwargs):

View File

@@ -21,6 +21,7 @@ class ROS2SerialNode(BaseROS2DeviceNode):
self.hardware_interface = Serial(baudrate=baudrate, port=port) self.hardware_interface = Serial(baudrate=baudrate, port=port)
except (OSError, SerialException) as e: except (OSError, SerialException) as e:
# 因为还没调用父类初始化,无法使用日志,直接抛出异常 # 因为还没调用父类初始化,无法使用日志,直接抛出异常
# print(f"Failed to connect to serial port {port} at {baudrate} baudrate.")
raise RuntimeError(f"Failed to connect to serial port {port} at {baudrate} baudrate.") from e raise RuntimeError(f"Failed to connect to serial port {port} at {baudrate} baudrate.") from e
# 初始化BaseROS2DeviceNode使用自身作为driver_instance # 初始化BaseROS2DeviceNode使用自身作为driver_instance
@@ -46,7 +47,7 @@ class ROS2SerialNode(BaseROS2DeviceNode):
self.lab_logger().info(f"【ROS2SerialNode.__init__】创建串口写入服务: serialwrite") self.lab_logger().info(f"【ROS2SerialNode.__init__】创建串口写入服务: serialwrite")
def send_command(self, command: str): def send_command(self, command: str):
self.lab_logger().info(f"【ROS2SerialNode.send_command】发送命令: {command}") # self.lab_logger().debug(f"【ROS2SerialNode.send_command】发送命令: {command}")
with self._query_lock: with self._query_lock:
if self._closing: if self._closing:
self.lab_logger().error(f"【ROS2SerialNode.send_command】设备正在关闭无法发送命令") self.lab_logger().error(f"【ROS2SerialNode.send_command】设备正在关闭无法发送命令")
@@ -58,23 +59,23 @@ class ROS2SerialNode(BaseROS2DeviceNode):
response = self.hardware_interface.write(full_command_data) response = self.hardware_interface.write(full_command_data)
# time.sleep(0.05) # time.sleep(0.05)
output = self._receive(self.hardware_interface.read_until(b"\n")) output = self._receive(self.hardware_interface.read_until(b"\n"))
self.lab_logger().info(f"【ROS2SerialNode.send_command】接收响应: {output}") # self.lab_logger().debug(f"【ROS2SerialNode.send_command】接收响应: {output}")
return output return output
def read_data(self): def read_data(self):
self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取数据") # self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取数据")
with self._query_lock: with self._query_lock:
if self._closing: if self._closing:
self.lab_logger().error(f"【ROS2SerialNode.read_data】设备正在关闭无法读取数据") self.lab_logger().error(f"【ROS2SerialNode.read_data】设备正在关闭无法读取数据")
raise RuntimeError raise RuntimeError
data = self.hardware_interface.read_until(b"\n") data = self.hardware_interface.read_until(b"\n")
result = self._receive(data) result = self._receive(data)
self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取到数据: {result}") # self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取到数据: {result}")
return result return result
def _receive(self, data: bytes): def _receive(self, data: bytes):
ascii_string = "".join(chr(byte) for byte in data) ascii_string = "".join(chr(byte) for byte in data)
self.lab_logger().debug(f"【ROS2SerialNode._receive】接收数据: {ascii_string}") # self.lab_logger().debug(f"【ROS2SerialNode._receive】接收数据: {ascii_string}")
return ascii_string return ascii_string
def handle_serial_request(self, request, response): def handle_serial_request(self, request, response):