其他修改,

This commit is contained in:
q434343
2026-03-31 14:57:51 +08:00
parent 6b3f9756a0
commit ad66fc1841
3 changed files with 148 additions and 387 deletions

View File

@@ -1,4 +1,95 @@
# CLAUDE.md
Please follow the rules defined in: This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md ## Build & Development
```bash
# Install (requires mamba env with python 3.11)
mamba create -n unilab python=3.11.14
mamba activate unilab
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
pip install -e .
uv pip install -r unilabos/utils/requirements.txt
# Run with a device graph
unilab --graph <graph.json> --config <config.py> --backend ros
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
# Common CLI flags
unilab --app_bridges websocket fastapi # communication bridges
unilab --test_mode # simulate hardware, no real execution
unilab --check_mode # CI validation of registry imports (AST-based)
unilab --skip_env_check # skip auto-install of dependencies
unilab --visual rviz|web|disable # visualization mode
unilab --is_slave # run as slave node
unilab --restart_mode # auto-restart on config changes (supervisor/child process)
# Workflow upload subcommand
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
# Tests
pytest tests/ # all tests
pytest tests/resources/test_resourcetreeset.py # single test file
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
# CI check (matches .github/workflows/ci-check.yml)
python -m unilabos --check_mode --skip_env_check
```
## Architecture
### Startup Flow
`unilab` CLI (entry point in `setup.py`) → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
### Core Layers
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices. Two registration mechanisms: YAML definitions in `registry/devices/*.yaml` and Python decorators (`@device`, `@action`, `@resource` in `registry/decorators.py`). AST scanning discovers decorated classes without importing them. Class paths resolved to Python classes via `utils/import_manager.py`.
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict``ResourceDictInstance``ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources. Graph I/O in `resources/graphio.py` reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`.
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by category (liquid_handling, hplc, balance, arm, etc.). Each driver class gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` into a `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`) with publishers, subscribers, and action servers.
**ROS2 Layer** (`unilabos/ros/`): Preset node types in `ros/nodes/presets/``host_node` (main orchestrator, ~90KB), `controller_node`, `workstation`, `serial_node`, `camera`, `resource_mesh_manager`. Custom messages in `unilabos_msgs/` (80+ action types, pre-built via conda `ros-humble-unilabos-msgs`).
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) registered in `__init__.py:action_protocol_generators` dict. Utility parsers in `compile/utils/` (vessel, unit, logger).
**Workflow** (`unilabos/workflow/`): Converts workflow definitions from multiple formats — JSON (`convert_from_json.py`, `common.py`), Python scripts (`from_python_script.py`), XDL (`from_xdl.py`) — into executable `WorkflowGraph`. Legacy converters in `workflow/legacy/`.
**Communication** (`unilabos/device_comms/`): Hardware adapters — OPC-UA, Modbus PLC, RPC, universal driver. `app/communication.py` provides factory pattern for WebSocket connections.
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 templates (`pages.py`), HTTP client (`client.py`). Default port 8002.
### Configuration System
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — class-level attributes, loaded from Python `.py` config files (see `config/example_config.py`)
- Environment variable overrides with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
- Device topology defined in graph files (JSON node-link format or GraphML)
### Key Data Flow
1. Graph file → `graphio.read_node_link_json()``(nx.Graph, ResourceTreeSet, resource_links)`
2. `ResourceTreeSet` + `Registry``initialize_device.initialize_device_from_dict()``ROS2DeviceNode` instances
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
### Test Data
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
## Code Conventions
- Code comments and log messages in **simplified Chinese**
- Python 3.11+, type hints expected
- Pydantic models for data validation (`resource_tracker.py`)
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
- CLI argument dashes auto-converted to underscores for consistency
- No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs)
- Documentation built with Sphinx (Chinese language, `sphinx_rtd_theme`, `myst_parser`)
## Licensing
- Framework code: GPL-3.0
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute

View File

@@ -22,6 +22,7 @@ from unilabos_msgs.srv import (
SerialCommand, SerialCommand,
) # type: ignore ) # type: ignore
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos_msgs.action import SendCmd
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.decorators import device, action, NodeType from unilabos.registry.decorators import device, action, NodeType
@@ -313,9 +314,15 @@ class HostNode(BaseROS2DeviceNode):
callback_group=self.callback_group, callback_group=self.callback_group,
), ),
} # 用来存储多个ActionClient实例 } # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = { self._add_resource_mesh_client = ActionClient(
device_id: self._action_value_mappings self,
} # device_id -> action_value_mappings(本地+远程设备统一存储) SendCmd,
"/devices/resource_mesh_manager/add_resource_mesh",
callback_group=self.callback_group,
)
self._action_value_mappings: Dict[str, Dict] = (
{}
) # device_id -> action_value_mappings(本地+远程设备统一存储)
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings) self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
@@ -1131,6 +1138,27 @@ class HostNode(BaseROS2DeviceNode):
), ),
} }
def _notify_resource_mesh_add(self, resource_tree_set: ResourceTreeSet):
"""通知 ResourceMeshManager 添加资源的 mesh 可视化"""
if not self._add_resource_mesh_client.server_is_ready():
self.lab_logger().debug("[Host Node] ResourceMeshManager 未就绪,跳过 mesh 添加通知")
return
resource_configs = []
for node in resource_tree_set.all_nodes:
res_dict = node.res_content.model_dump(by_alias=True)
if res_dict.get("type") == "device":
continue
resource_configs.append(res_dict)
if not resource_configs:
return
goal_msg = SendCmd.Goal()
goal_msg.command = json.dumps({"resources": resource_configs})
self._add_resource_mesh_client.send_goal_async(goal_msg)
self.lab_logger().info(f"[Host Node] 已发送 {len(resource_configs)} 个资源 mesh 添加请求")
async def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK async def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK
resource_tree_set = ResourceTreeSet.load(data["data"]) resource_tree_set = ResourceTreeSet.load(data["data"])
mount_uuid = data["mount_uuid"] mount_uuid = data["mount_uuid"]
@@ -1171,6 +1199,12 @@ class HostNode(BaseROS2DeviceNode):
response.response = json.dumps(uuid_mapping) if success else "FAILED" response.response = json.dumps(uuid_mapping) if success else "FAILED"
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
if success:
try:
self._notify_resource_mesh_add(resource_tree_set)
except Exception as e:
self.lab_logger().error(f"[Host Node] 通知 ResourceMeshManager 添加 mesh 失败: {e}")
async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
uuid_list: List[str] = data["data"] uuid_list: List[str] = data["data"]
with_children: bool = data["with_children"] with_children: bool = data["with_children"]
@@ -1222,6 +1256,12 @@ class HostNode(BaseROS2DeviceNode):
response.response = json.dumps(uuid_mapping) response.response = json.dumps(uuid_mapping)
self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}") self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}")
if success:
try:
self._notify_resource_mesh_add(new_tree_set)
except Exception as e:
self.lab_logger().error(f"[Host Node] 通知 ResourceMeshManager 更新 mesh 失败: {e}")
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response): async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
""" """
子节点通知Host物料树更新 子节点通知Host物料树更新

View File

@@ -8,8 +8,8 @@
"parent": "", "parent": "",
"pose": { "pose": {
"size": { "size": {
"width": 562, "width": 550,
"height": 394, "height": 400,
"depth": 0 "depth": 0
} }
}, },
@@ -21,13 +21,14 @@
}, },
"host": "10.20.30.184", "host": "10.20.30.184",
"port": 9999, "port": 9999,
"debug": true, "debug": false,
"setup": true, "setup": false,
"is_9320": true, "is_9320": true,
"timeout": 10, "timeout": 10,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", "matrix_id": "",
"simulator": true, "simulator": false,
"channel_num": 2 "channel_num": 2,
"step_mode": true
}, },
"data": { "data": {
"reset_ok": true "reset_ok": true
@@ -55,9 +56,9 @@
}, },
"config": { "config": {
"type": "PRCXI9300Deck", "type": "PRCXI9300Deck",
"size_x": 542, "size_x": 550,
"size_y": 374, "size_y": 400,
"size_z": 0, "size_z": 17,
"rotation": { "rotation": {
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -66,378 +67,7 @@
}, },
"category": "deck", "category": "deck",
"barcode": null, "barcode": null,
"preferred_pickup_location": null, "preferred_pickup_location": null
"sites": [
{
"label": "T1",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"container",
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T2",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T3",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T4",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T5",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T6",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T7",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T9",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T10",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T11",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T14",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T15",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T16",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
}
]
}, },
"data": {} "data": {}
} }