From ad66fc1841f7143548314f193087ba485b9a87ca Mon Sep 17 00:00:00 2001 From: q434343 <554662886@qq.com> Date: Tue, 31 Mar 2026 14:57:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B6=E4=BB=96=E4=BF=AE=E6=94=B9=EF=BC=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 95 ++++- unilabos/ros/nodes/presets/host_node.py | 46 +- .../test/experiments/prcxi_9320_slim.json | 394 +----------------- 3 files changed, 148 insertions(+), 387 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd5ce566..16fa5732 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --config --backend ros +unilab --graph --config --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 -n --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 diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 2cac28f4..65de69e1 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -22,6 +22,7 @@ from unilabos_msgs.srv import ( SerialCommand, ) # type: ignore 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 unilabos.registry.decorators import device, action, NodeType @@ -313,9 +314,15 @@ class HostNode(BaseROS2DeviceNode): callback_group=self.callback_group, ), } # 用来存储多个ActionClient实例 - self._action_value_mappings: Dict[str, Dict] = { - device_id: self._action_value_mappings - } # device_id -> action_value_mappings(本地+远程设备统一存储) + self._add_resource_mesh_client = ActionClient( + self, + 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._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 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 resource_tree_set = ResourceTreeSet.load(data["data"]) mount_uuid = data["mount_uuid"] @@ -1171,6 +1199,12 @@ class HostNode(BaseROS2DeviceNode): response.response = json.dumps(uuid_mapping) if success else "FAILED" 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 uuid_list: List[str] = data["data"] with_children: bool = data["with_children"] @@ -1222,6 +1256,12 @@ class HostNode(BaseROS2DeviceNode): response.response = json.dumps(uuid_mapping) 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): """ 子节点通知Host物料树更新 diff --git a/unilabos/test/experiments/prcxi_9320_slim.json b/unilabos/test/experiments/prcxi_9320_slim.json index 2aaee6a7..eced1e71 100644 --- a/unilabos/test/experiments/prcxi_9320_slim.json +++ b/unilabos/test/experiments/prcxi_9320_slim.json @@ -8,8 +8,8 @@ "parent": "", "pose": { "size": { - "width": 562, - "height": 394, + "width": 550, + "height": 400, "depth": 0 } }, @@ -21,13 +21,14 @@ }, "host": "10.20.30.184", "port": 9999, - "debug": true, - "setup": true, + "debug": false, + "setup": false, "is_9320": true, "timeout": 10, - "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", - "simulator": true, - "channel_num": 2 + "matrix_id": "", + "simulator": false, + "channel_num": 2, + "step_mode": true }, "data": { "reset_ok": true @@ -55,9 +56,9 @@ }, "config": { "type": "PRCXI9300Deck", - "size_x": 542, - "size_y": 374, - "size_z": 0, + "size_x": 550, + "size_y": 400, + "size_z": 17, "rotation": { "x": 0, "y": 0, @@ -66,378 +67,7 @@ }, "category": "deck", "barcode": 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" - ] - } - ] + "preferred_pickup_location": null }, "data": {} }