diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md new file mode 100644 index 00000000..d2272030 --- /dev/null +++ b/.cursor/skills/add-device/SKILL.md @@ -0,0 +1,24 @@ +--- +name: add-device +description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Walks through device category selection (thing model), communication protocol, command protocol collection, driver creation, registry YAML, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, configure device registry, or mentions 接入设备/添加设备/设备驱动/物模型. +--- + +# 添加新设备到 Uni-Lab-OS + +**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南并严格遵循。 + +该指南包含: +- 8 步完整流程(设备类别、通信协议、指令收集、接口对齐、驱动创建、注册表、图文件、验证) +- 所有物模型代码模板(注射泵、电磁阀、蠕动泵、温控、电机等) +- 通信协议代码片段(Serial、Modbus、TCP、HTTP、OPC UA) +- 现有设备接口快照(用于第四步对齐,包含参数名、status_types、方法签名) +- 常见错误检查清单 + +**Cursor 工具映射:** + +| 指南中的操作 | Cursor 中使用的工具 | +|---|---| +| 向用户确认设备类别、协议等信息 | 使用 AskQuestion 工具 | +| 搜索已有设备注册表 | 使用 Grep 在 `unilabos/registry/devices/` 中搜索 | +| 读取用户提供的协议文档/SDK 代码 | 使用 Read 工具 | +| 第四步对齐:查找同类设备接口 | 优先使用 Grep 搜索仓库中的最新注册表;指南中的「现有设备接口快照」作为兜底参考 | diff --git a/.cursor/skills/add-protocol/SKILL.md b/.cursor/skills/add-protocol/SKILL.md new file mode 100644 index 00000000..2537051b --- /dev/null +++ b/.cursor/skills/add-protocol/SKILL.md @@ -0,0 +1,323 @@ +--- +name: add-protocol +description: Guide for adding new experiment protocols to Uni-Lab-OS (添加新实验操作协议). Walks through ROS Action definition, Pydantic model creation, protocol generator implementation, and registration. Use when the user wants to add a new protocol, create a compile function, implement an experiment operation, or mentions 协议/protocol/编译/compile/实验操作. +--- + +# 添加新实验操作协议(Protocol) + +Protocol 是对实验有意义的完整动作(如泵转移、过滤、溶解),需要多设备协同。`compile/` 中的生成函数根据设备连接图将抽象操作"编译"为设备指令序列。 + +添加一个 Protocol 需修改 **6 个文件**,按以下流程执行。 + +--- + +## 第一步:确认协议信息 + +向用户确认: + +| 信息 | 示例 | +|------|------| +| 协议英文名 | `MyNewProtocol` | +| 操作描述 | 将固体样品研磨至目标粒径 | +| Goal 参数(必需 + 可选) | `vessel: dict`, `time: float = 300.0` | +| Result 字段 | `success: bool`, `message: str` | +| 需要哪些设备协同 | 研磨器、搅拌器 | + +--- + +## 第二步:创建 ROS Action 定义 + +路径:`unilabos_msgs/action/.action` + +三段式结构(Goal / Result / Feedback),用 `---` 分隔: + +``` +# Goal +Resource vessel +float64 time +string mode +--- +# Result +bool success +string return_info +--- +# Feedback +string status +string current_device +builtin_interfaces/Duration time_spent +builtin_interfaces/Duration time_remaining +``` + +**类型映射:** + +| Python 类型 | ROS 类型 | 说明 | +|------------|----------|------| +| `dict` | `Resource` | 容器/设备引用,自定义消息类型 | +| `float` | `float64` | | +| `int` | `int32` | | +| `str` | `string` | | +| `bool` | `bool` | | + +> `Resource` 是 `unilabos_msgs/msg/Resource.msg` 中定义的自定义消息类型。 + +--- + +## 第三步:注册 Action 到 CMakeLists + +在 `unilabos_msgs/CMakeLists.txt` 的 `set(action_files ...)` 块中添加: + +```cmake +"action/MyNewAction.action" +``` + +> 调试时需编译:`cd unilabos_msgs && colcon build && source ./install/local_setup.sh && cd ..` +> PR 合并后 CI/CD 自动发布,`mamba update ros-humble-unilabos-msgs` 即可。 + +--- + +## 第四步:创建 Pydantic 模型 + +在 `unilabos/messages/__init__.py` 中添加(位于 `# Start Protocols` 和 `# End Protocols` 之间): + +```python +class MyNewProtocol(BaseModel): + # === 必需参数 === + vessel: dict = Field(..., description="目标容器") + + # === 可选参数 === + time: float = Field(300.0, description="操作时间 (秒)") + mode: str = Field("default", description="操作模式") + + def model_post_init(self, __context): + """参数验证和修正""" + if self.time <= 0: + self.time = 300.0 +``` + +**规则:** +- 参数名必须与 `.action` 文件中 Goal 字段完全一致 +- `dict` 类型对应 `.action` 中的 `Resource` +- 将类名加入文件末尾的 `__all__` 列表 + +--- + +## 第五步:实现协议生成函数 + +路径:`unilabos/compile/_protocol.py` + +```python +import networkx as nx +from typing import List, Dict, Any + + +def generate_my_new_protocol( + G: nx.DiGraph, + vessel: dict, + time: float = 300.0, + mode: str = "default", + **kwargs, +) -> List[Dict[str, Any]]: + """将 MyNewProtocol 编译为设备动作序列。 + + Args: + G: 设备连接图(NetworkX),节点为设备/容器,边为物理连接 + vessel: 目标容器 {"id": "reactor_1"} + time: 操作时间(秒) + mode: 操作模式 + + Returns: + 动作列表,每个元素为: + - dict: 单步动作 + - list[dict]: 并行动作 + """ + from unilabos.compile.utils.vessel_parser import get_vessel + + vessel_id, vessel_data = get_vessel(vessel) + actions = [] + + # 查找相关设备(通过图的连接关系) + # 生成动作序列 + actions.append({ + "device_id": "target_device_id", + "action_name": "some_action", + "action_kwargs": {"param": "value"} + }) + + # 等待 + actions.append({ + "action_name": "wait", + "action_kwargs": {"time": time} + }) + + return actions +``` + +### 动作字典格式 + +```python +# 单步动作(发给子设备) +{"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}} + +# 发给工作站自身 +{"device_id": "self", "action_name": "my_action", "action_kwargs": {...}} + +# 等待 +{"action_name": "wait", "action_kwargs": {"time": 5.0}} + +# 并行动作(列表嵌套) +[ + {"device_id": "pump_1", "action_name": "set_position", "action_kwargs": {"position": 10.0}}, + {"device_id": "stirrer_1", "action_name": "start_stir", "action_kwargs": {"stir_speed": 300}} +] +``` + +### 关于 `vessel` 参数类型 + +现有协议的 `vessel` 参数类型不统一: +- 新协议趋势:使用 `dict`(如 `{"id": "reactor_1"}`) +- 旧协议:使用 `str`(如 `"reactor_1"`) +- 兼容写法:`Union[str, dict]` + +**建议新协议统一使用 `dict` 类型**,通过 `get_vessel()` 兼容两种输入。 + +### 公共工具函数(`unilabos/compile/utils/`) + +| 函数 | 用途 | +|------|------| +| `get_vessel(vessel)` | 解析容器参数为 `(vessel_id, vessel_data)`,兼容 dict 和 str | +| `find_solvent_vessel(G, solvent)` | 根据溶剂名查找容器(精确→命名规则→模糊→液体类型) | +| `find_reagent_vessel(G, reagent)` | 根据试剂名查找容器(支持固体和液体) | +| `find_connected_stirrer(G, vessel)` | 查找与容器相连的搅拌器 | +| `find_solid_dispenser(G)` | 查找固体加样器 | + +### 协议内专属查找函数 + +许多协议在自己的文件内定义了专属的 `find_*` 函数(不在 `utils/` 中)。编写新协议时,优先复用 `utils/` 中的公共函数;如需特殊查找逻辑,在协议文件内部定义即可: + +```python +def find_my_special_device(G: nx.DiGraph, vessel: str) -> str: + """查找与容器相关的特殊设备""" + for node in G.nodes(): + if 'my_device_type' in G.nodes[node].get('class', '').lower(): + return node + raise ValueError("未找到特殊设备") +``` + +### 复用已有协议 + +复杂协议通常组合已有协议: + +```python +from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing + +actions.extend(generate_pump_protocol_with_rinsing( + G, from_vessel=solvent_vessel, to_vessel=vessel, volume=volume +)) +``` + +### 图查询模式 + +```python +# 查找与容器相连的特定类型设备 +for neighbor in G.neighbors(vessel_id): + node_data = G.nodes[neighbor] + if "heater" in node_data.get("class", ""): + heater_id = neighbor + break + +# 查找最短路径(泵转移) +path = nx.shortest_path(G, source=from_vessel_id, target=to_vessel_id) +``` + +--- + +## 第六步:注册协议生成函数 + +在 `unilabos/compile/__init__.py` 中: + +1. 顶部添加导入: + +```python +from .my_new_protocol import generate_my_new_protocol +``` + +2. 在 `action_protocol_generators` 字典中添加映射: + +```python +action_protocol_generators = { + # ... 已有协议 + MyNewProtocol: generate_my_new_protocol, +} +``` + +--- + +## 第七步:配置图文件 + +在工作站的图文件中,将协议名加入 `protocol_type`: + +```json +{ + "id": "my_station", + "class": "workstation", + "config": { + "protocol_type": ["PumpTransferProtocol", "MyNewProtocol"] + } +} +``` + +--- + +## 第八步:验证 + +```bash +# 1. 模块可导入 +python -c "from unilabos.messages import MyNewProtocol; print(MyNewProtocol.model_fields)" + +# 2. 生成函数可导入 +python -c "from unilabos.compile import action_protocol_generators; print(list(action_protocol_generators.keys()))" + +# 3. 启动测试(可选) +unilab -g .json --complete_registry +``` + +--- + +## 工作流清单 + +``` +协议接入进度: +- [ ] 1. 确认协议名、参数、涉及设备 +- [ ] 2. 创建 .action 文件 (unilabos_msgs/action/.action) +- [ ] 3. 注册到 CMakeLists.txt +- [ ] 4. 创建 Pydantic 模型 (unilabos/messages/__init__.py) + 更新 __all__ +- [ ] 5. 实现生成函数 (unilabos/compile/_protocol.py) +- [ ] 6. 注册到 compile/__init__.py +- [ ] 7. 配置图文件 protocol_type +- [ ] 8. 验证 +``` + +--- + +## 高级模式 + +实现复杂协议时,详见 [reference.md](reference.md):协议运行时数据流、mock graph 测试模式、单位解析工具(`unit_parser.py`)、复杂协议组合模式(以 dissolve 为例)。 + +--- + +## 现有协议速查 + +| 协议 | Pydantic 类 | 生成函数 | 核心参数 | +|------|-------------|---------|---------| +| 泵转移 | `PumpTransferProtocol` | `generate_pump_protocol_with_rinsing` | `from_vessel, to_vessel, volume` | +| 简单转移 | `TransferProtocol` | `generate_pump_protocol` | `from_vessel, to_vessel, volume` | +| 加样 | `AddProtocol` | `generate_add_protocol` | `vessel, reagent, volume` | +| 过滤 | `FilterProtocol` | `generate_filter_protocol` | `vessel, filtrate_vessel` | +| 溶解 | `DissolveProtocol` | `generate_dissolve_protocol` | `vessel, solvent, volume` | +| 加热/冷却 | `HeatChillProtocol` | `generate_heat_chill_protocol` | `vessel, temp, time` | +| 搅拌 | `StirProtocol` | `generate_stir_protocol` | `vessel, time` | +| 分离 | `SeparateProtocol` | `generate_separate_protocol` | `from_vessel, separation_vessel, solvent` | +| 蒸发 | `EvaporateProtocol` | `generate_evaporate_protocol` | `vessel, pressure, temp, time` | +| 清洗 | `CleanProtocol` | `generate_clean_protocol` | `vessel, solvent, volume` | +| 离心 | `CentrifugeProtocol` | `generate_centrifuge_protocol` | `vessel, speed, time` | +| 抽气充气 | `EvacuateAndRefillProtocol` | `generate_evacuateandrefill_protocol` | `vessel, gas` | diff --git a/.cursor/skills/add-protocol/reference.md b/.cursor/skills/add-protocol/reference.md new file mode 100644 index 00000000..a212ced9 --- /dev/null +++ b/.cursor/skills/add-protocol/reference.md @@ -0,0 +1,207 @@ +# 协议高级参考 + +本文件是 SKILL.md 的补充,包含协议运行时数据流、测试模式、单位解析工具和复杂协议组合模式。Agent 在需要实现这些功能时按需阅读。 + +--- + +## 1. 协议运行时数据流 + +从图文件到协议执行的完整链路: + +``` +实验图 JSON + ↓ graphio.read_node_link_json() +physical_setup_graph (NetworkX DiGraph) + ↓ ROS2WorkstationNode._setup_protocol_names(protocol_type) +为每个 protocol_name 创建 ActionServer + ↓ 收到 Action Goal +_create_protocol_execute_callback() + ↓ convert_from_ros_msg_with_mapping(goal, mapping) +protocol_kwargs (Python dict) + ↓ 向 Host 查询 Resource 类型参数的当前状态 +protocol_kwargs 更新(vessel 带上 children、data 等) + ↓ protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs) +List[Dict] 动作序列 + ↓ 逐步 execute_single_action / 并行 create_task +子设备 ActionClient 执行 +``` + +### `_setup_protocol_names` 核心逻辑 + +```python +def _setup_protocol_names(self, protocol_type): + if isinstance(protocol_type, str): + self.protocol_names = [p.strip() for p in protocol_type.split(",")] + else: + self.protocol_names = protocol_type + self.protocol_action_mappings = {} + for protocol_name in self.protocol_names: + protocol_type = globals()[protocol_name] # 从 messages 模块取 Pydantic 类 + self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type) +``` + +### `_create_protocol_execute_callback` 关键步骤 + +1. `convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])` — ROS Goal → Python dict +2. 对 `Resource` 类型字段,通过 `resource_get` Service 查询 Host 的最新资源状态 +3. `protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs)` — 调用编译函数 +4. 遍历 steps:`dict` 串行执行,`list` 并行执行 +5. `execute_single_action` 通过 `_action_clients[device_id]` 向子设备发送 Action Goal +6. 执行完毕后通过 `resource_update` Service 更新资源状态 + +--- + +## 2. 测试模式 + +### 2.1 协议文件内测试函数 + +许多协议文件末尾有 `test_*` 函数,主要测试参数解析工具: + +```python +def test_dissolve_protocol(): + """测试溶解协议的各种参数解析""" + volumes = ["10 mL", "?", 10.0, "1 L", "500 μL"] + for vol in volumes: + result = parse_volume_input(vol) + print(f"体积解析: {vol} → {result}mL") + + masses = ["2.9 g", "?", 2.5, "500 mg"] + for mass in masses: + result = parse_mass_input(mass) + print(f"质量解析: {mass} → {result}g") +``` + +### 2.2 使用 mock graph 测试协议生成器 + +推荐的端到端测试模式: + +```python +import pytest +import networkx as nx +from unilabos.compile.stir_protocol import generate_stir_protocol + + +@pytest.fixture +def topology_graph(): + """创建测试拓扑图""" + G = nx.DiGraph() + G.add_node("flask_1", **{"class": "flask", "type": "container"}) + G.add_node("stirrer_1", **{"class": "virtual_stirrer", "type": "device"}) + G.add_edge("stirrer_1", "flask_1") + return G + + +def test_generate_stir_protocol(topology_graph): + """测试搅拌协议生成""" + actions = generate_stir_protocol( + G=topology_graph, + vessel="flask_1", + time="5 min", + stir_speed=300.0 + ) + assert len(actions) >= 1 + assert actions[0]["device_id"] == "stirrer_1" +``` + +**要点:** +- 用 `nx.DiGraph()` 构建最小拓扑 +- `add_node(id, **attrs)` 设置 `class`、`type`、`data` 等 +- `add_edge(src, dst)` 建立物理连接 +- 协议内的 `find_*` 函数依赖这些节点和边 + +--- + +## 3. 单位解析工具 + +路径:`unilabos/compile/utils/unit_parser.py` + +| 函数 | 输入 | 返回 | 默认值 | +|------|------|------|--------| +| `parse_volume_input(input, default_unit)` | `"100 mL"`, `"2.5 L"`, `"500 μL"`, `10.0`, `"?"` | mL (float) | 50.0 | +| `parse_mass_input(input)` | `"19.3 g"`, `"500 mg"`, `2.5`, `"?"` | g (float) | 1.0 | +| `parse_time_input(input)` | `"30 min"`, `"1 h"`, `"300"`, `60.0`, `"?"` | 秒 (float) | 60.0 | + +支持的单位: + +- **体积**: mL, L, μL/uL, milliliter, liter, microliter +- **质量**: g, mg, kg, gram, milligram, kilogram +- **时间**: s/sec/second, min/minute, h/hr/hour, d/day + +特殊值 `"?"`、`"unknown"`、`"tbd"` 返回默认值。 + +--- + +## 4. 复杂协议组合模式 + +以 `dissolve_protocol` 为例,展示如何组合多个子操作: + +### 整体流程 + +``` +1. 解析参数 (parse_volume_input, parse_mass_input, parse_time_input) +2. 设备发现 (find_connected_heatchill, find_connected_stirrer, find_solid_dispenser) +3. 判断溶解类型 (液体 vs 固体) +4. 组合动作序列: + a. heat_chill_start / start_stir (启动加热/搅拌) + b. wait (等待温度稳定) + c. pump_protocol_with_rinsing (液体转移, 通过 extend 拼接) + 或 add_solid (固体加样) + d. heat_chill / stir / wait (溶解等待) + e. heat_chill_stop (停止加热) +``` + +### 关键代码模式 + +**设备发现 → 条件组合:** + +```python +heatchill_id = find_connected_heatchill(G, vessel_id) +stirrer_id = find_connected_stirrer(G, vessel_id) +solid_dispenser_id = find_solid_dispenser(G) + +actions = [] + +# 启动阶段 +if heatchill_id and temp > 25.0: + actions.append({ + "device_id": heatchill_id, + "action_name": "heat_chill_start", + "action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp} + }) + actions.append({"action_name": "wait", "action_kwargs": {"time": 30}}) +elif stirrer_id: + actions.append({ + "device_id": stirrer_id, + "action_name": "start_stir", + "action_kwargs": {"vessel": {"id": vessel_id}, "stir_speed": stir_speed} + }) + +# 转移阶段(复用已有协议) +pump_actions = generate_pump_protocol_with_rinsing( + G=G, from_vessel=solvent_vessel, to_vessel=vessel_id, volume=volume +) +actions.extend(pump_actions) + +# 等待阶段 +if heatchill_id: + actions.append({ + "device_id": heatchill_id, + "action_name": "heat_chill", + "action_kwargs": {"vessel": {"id": vessel_id}, "temp": temp, "time": time} + }) +else: + actions.append({"action_name": "wait", "action_kwargs": {"time": time}}) +``` + +--- + +## 5. 关键路径 + +| 内容 | 路径 | +|------|------| +| 协议执行回调 | `unilabos/ros/nodes/presets/workstation.py` | +| ROS 消息映射 | `unilabos/ros/msgs/message_converter.py` | +| 物理拓扑图 | `unilabos/resources/graphio.py` (`physical_setup_graph`) | +| 单位解析 | `unilabos/compile/utils/unit_parser.py` | +| 容器解析 | `unilabos/compile/utils/vessel_parser.py` | +| 溶解协议(组合示例) | `unilabos/compile/dissolve_protocol.py` | diff --git a/.cursor/skills/add-resource/SKILL.md b/.cursor/skills/add-resource/SKILL.md new file mode 100644 index 00000000..29b1bdff --- /dev/null +++ b/.cursor/skills/add-resource/SKILL.md @@ -0,0 +1,371 @@ +--- +name: add-resource +description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Covers Bottle, Carrier, Deck, WareHouse definitions and registry YAML. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse. +--- + +# 添加新物料资源 + +Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。 + +--- + +## 第一步:确认资源类型 + +向用户确认需要添加的资源类型: + +| 类型 | 基类 | 用途 | 示例 | +|------|------|------|------| +| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 | +| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 | +| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 | +| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck | + +**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle` + +还需确认: +- 资源所属的项目/场景(如 bioyond、battery、通用) +- 尺寸参数(直径、高度、最大容积等) +- 布局参数(行列数、间距等) + +--- + +## 第二步:创建资源定义 + +### 文件位置 + +``` +unilabos/resources/ +├── / # 按项目分组 +│ ├── bottles.py # Bottle 工厂函数 +│ ├── bottle_carriers.py # Carrier 工厂函数 +│ ├── warehouses.py # WareHouse 工厂函数 +│ └── decks.py # Deck 类定义 +├── itemized_carrier.py # Bottle, BottleCarrier, ItemizedCarrier 基类 +├── warehouse.py # WareHouse 基类 +└── container.py # 通用容器 +``` + +### 2A. 添加 Bottle(工厂函数) + +```python +from unilabos.resources.itemized_carrier import Bottle + + +def My_Reagent_Bottle( + name: str, + diameter: float = 70.0, # 瓶体直径 (mm) + height: float = 120.0, # 瓶体高度 (mm) + max_volume: float = 500000.0, # 最大容积 (μL) + barcode: str = None, +) -> Bottle: + """创建试剂瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="My_Reagent_Bottle", # 唯一标识,用于注册表和物料映射 + ) +``` + +**Bottle 参数:** +- `name`: 实例名称(运行时唯一) +- `diameter`: 瓶体直径 (mm) +- `height`: 瓶体高度 (mm) +- `max_volume`: 最大容积 (**μL**,注意单位!500mL = 500000) +- `barcode`: 条形码(可选) +- `model`: 模型标识,与注册表 key 一致 + +### 2B. 添加 BottleCarrier(工厂函数) + +```python +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.carrier import create_ordered_items_2d + +from unilabos.resources.itemized_carrier import BottleCarrier + + +def My_6SlotCarrier(name: str) -> BottleCarrier: + """创建 3x2 六槽位载架""" + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, # 列数 + num_items_y=2, # 行数 + dx=10.0, # X 起始偏移 + dy=10.0, # Y 起始偏移 + dz=5.0, # Z 偏移 + item_dx=42.0, # X 间距 + item_dy=35.0, # Y 间距 + size_x=20.0, # 槽位宽 + size_y=20.0, # 槽位深 + size_z=50.0, # 槽位高 + ) + carrier = BottleCarrier( + name=name, + size_x=146.0, # 载架总宽 + size_y=80.0, # 载架总深 + size_z=55.0, # 载架总高 + sites=sites, + model="My_6SlotCarrier", + ) + carrier.num_items_x = 3 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + + # 预装 Bottle(可选) + ordering = ["A01", "A02", "A03", "B01", "B02", "B03"] + for i in range(6): + carrier[i] = My_Reagent_Bottle(f"{ordering[i]}") + + return carrier +``` + +### 2C. 添加 WareHouse(工厂函数) + +```python +from unilabos.resources.warehouse import warehouse_factory + + +def my_warehouse_4x4(name: str) -> "WareHouse": + """创建 4行x4列 堆栈仓库""" + return warehouse_factory( + name=name, + num_items_x=4, # 列数 + num_items_y=4, # 行数 + num_items_z=1, # 层数(通常为 1) + dx=137.0, # X 起始偏移 + dy=96.0, # Y 起始偏移 + dz=120.0, # Z 起始偏移 + item_dx=137.0, # X 间距 + item_dy=125.0, # Y 间距 + item_dz=10.0, # Z 间距(多层时用) + resource_size_x=127.0, # 槽位宽 + resource_size_y=85.0, # 槽位深 + resource_size_z=100.0, # 槽位高 + model="my_warehouse_4x4", + ) +``` + +**`warehouse_factory` 参数说明:** + +| 参数 | 说明 | +|------|------| +| `num_items_x/y/z` | 列数/行数/层数 | +| `dx, dy, dz` | 第一个槽位的起始坐标偏移 | +| `item_dx, item_dy, item_dz` | 相邻槽位间距 | +| `resource_size_x/y/z` | 单个槽位的物理尺寸 | +| `col_offset` | 列命名偏移(如设 4 则从 A05 开始) | +| `row_offset` | 行命名偏移(如设 5 则从 F 行开始) | +| `layout` | 排序方式:`"col-major"`(列优先,默认)/ `"row-major"`(行优先) | +| `removed_positions` | 要移除的位置索引列表 | + +自动生成 `ResourceHolder` 槽位,命名规则为 `A01, B01, C01, D01, A02, ...`(列优先)或 `A01, A02, A03, A04, B01, ...`(行优先)。 + +### 2D. 添加 Deck(类定义) + +```python +from pylabrobot.resources import Deck, Coordinate + + +class MyStation_Deck(Deck): + def __init__( + self, + name: str = "MyStation_Deck", + size_x: float = 2700.0, + size_y: float = 1080.0, + size_z: float = 1500.0, + category: str = "deck", + setup: bool = False, + ) -> None: + super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z) + if setup: + self.setup() + + def setup(self) -> None: + self.warehouses = { + "仓库A": my_warehouse_4x4("仓库A"), + "仓库B": my_warehouse_4x4("仓库B"), + } + self.warehouse_locations = { + "仓库A": Coordinate(-200.0, 400.0, 0.0), + "仓库B": Coordinate(2350.0, 400.0, 0.0), + } + for wh_name, wh in self.warehouses.items(): + self.assign_child_resource(wh, location=self.warehouse_locations[wh_name]) +``` + +**Deck 要点:** +- 继承 `pylabrobot.resources.Deck` +- `setup()` 创建 WareHouse 并通过 `assign_child_resource` 放置到指定坐标 +- `setup` 参数控制是否在构造时自动调用 `setup()`(图文件中通过 `config.setup: true` 触发) + +--- + +## 第三步:创建注册表 YAML + +路径:`unilabos/registry/resources//.yaml` + +### Bottle 注册 + +```yaml +My_Reagent_Bottle: + category: + - bottles + class: + module: unilabos.resources.my_project.bottles:My_Reagent_Bottle + type: pylabrobot + description: 我的试剂瓶 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +``` + +### Carrier 注册 + +```yaml +My_6SlotCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.my_project.bottle_carriers:My_6SlotCarrier + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +``` + +### Deck 注册 + +```yaml +MyStation_Deck: + category: + - deck + class: + module: unilabos.resources.my_project.decks:MyStation_Deck + type: pylabrobot + description: 我的工作站 Deck + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +``` + +**注册表规则:** +- `class.module` 格式为 `python.module.path:ClassName_or_FunctionName` +- `class.type` 固定为 `pylabrobot` +- Key(如 `My_Reagent_Bottle`)必须与工厂函数名 / 类名一致 +- `category` 按类型标注(`bottles`, `bottle_carriers`, `deck` 等) + +--- + +## 第四步:在图文件中引用 + +### Deck 在工作站中的引用 + +工作站节点通过 `deck` 字段引用,Deck 作为子节点: + +```json +{ + "id": "my_station", + "children": ["my_deck"], + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_project.decks:MyStation_Deck" + } + } +}, +{ + "id": "my_deck", + "parent": "my_station", + "type": "deck", + "class": "MyStation_Deck", + "config": {"type": "MyStation_Deck", "setup": true} +} +``` + +### 物料类型映射(外部系统对接时) + +如果工作站需要与外部系统同步物料,在 config 中配置 `material_type_mappings`: + +```json +"material_type_mappings": { + "My_Reagent_Bottle": ["试剂瓶", "external-type-uuid"], + "My_6SlotCarrier": ["六槽载架", "external-type-uuid"] +} +``` + +--- + +## 第五步:注册 PLR 扩展(如需要) + +如果添加了新的 Deck 类,需要在 `unilabos/resources/plr_additional_res_reg.py` 中导入,使 `find_subclass` 能发现它: + +```python +def register(): + from unilabos.resources.my_project.decks import MyStation_Deck +``` + +--- + +## 第六步:验证 + +```bash +# 1. 资源可导入 +python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))" + +# 2. Deck 可创建 +python -c " +from unilabos.resources.my_project.decks import MyStation_Deck +d = MyStation_Deck('test', setup=True) +print(d.children) +" + +# 3. 启动测试 +unilab -g .json --complete_registry +``` + +--- + +## 工作流清单 + +``` +资源接入进度: +- [ ] 1. 确定资源类型(Bottle / Carrier / WareHouse / Deck) +- [ ] 2. 创建资源定义(工厂函数/类) +- [ ] 3. 创建注册表 YAML (unilabos/registry/resources//.yaml) +- [ ] 4. 在图文件中引用(如需要) +- [ ] 5. 注册 PLR 扩展(Deck 类需要) +- [ ] 6. 验证 +``` + +--- + +## 高级模式 + +实现复杂资源系统时,详见 [reference.md](reference.md):类继承体系完整图、序列化/反序列化流程、Bioyond 物料双向同步、非瓶类资源(ElectrodeSheet / Magazine)、仓库工厂 layout 模式。 + +--- + +## 现有资源参考 + +| 项目 | Bottles | Carriers | WareHouses | Decks | +|------|---------|----------|------------|-------| +| bioyond | `bioyond/bottles.py` | `bioyond/bottle_carriers.py` | `bioyond/warehouses.py`, `YB_warehouses.py` | `bioyond/decks.py` | +| battery | — | `battery/bottle_carriers.py` | — | — | +| 通用 | — | — | `warehouse.py` | — | + +### 关键路径 + +| 内容 | 路径 | +|------|------| +| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` | +| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` | +| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` | +| 资源注册表 | `unilabos/registry/resources/` | +| 图文件加载 | `unilabos/resources/graphio.py` | +| 资源跟踪器 | `unilabos/resources/resource_tracker.py` | diff --git a/.cursor/skills/add-resource/reference.md b/.cursor/skills/add-resource/reference.md new file mode 100644 index 00000000..a227d0c8 --- /dev/null +++ b/.cursor/skills/add-resource/reference.md @@ -0,0 +1,292 @@ +# 资源高级参考 + +本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。 + +--- + +## 1. 类继承体系 + +``` +PyLabRobot +├── Resource (PLR 基类) +│ ├── Well +│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器 +│ ├── Deck +│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面 +│ ├── ResourceHolder → 槽位占位符 +│ └── Container +│ └── Battery (unilabos) → 组装好的电池 +│ +├── ItemizedCarrier (unilabos, 继承 Resource) +│ ├── BottleCarrier (unilabos) → 瓶载架 +│ └── WareHouse (unilabos) → 堆栈仓库 +│ +├── ItemizedResource (PLR) +│ └── MagazineHolder (unilabos) → 子弹夹载架 +│ +└── ResourceStack (PLR) + └── Magazine (unilabos) → 子弹夹洞位 +``` + +### Bottle 类细节 + +```python +class Bottle(Well): + def __init__(self, name, diameter, height, max_volume, + size_x=0.0, size_y=0.0, size_z=0.0, + barcode=None, category="container", model=None, **kwargs): + super().__init__( + name=name, + size_x=diameter, # PLR 用 diameter 作为 size_x/size_y + size_y=diameter, + size_z=height, # PLR 用 height 作为 size_z + max_volume=max_volume, + category=category, + model=model, + bottom_type="flat", + cross_section_type="circle" + ) +``` + +注意 `size_x = size_y = diameter`,`size_z = height`。 + +### ItemizedCarrier 核心方法 + +| 方法 | 说明 | +|------|------| +| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 | +| `__setitem__(identifier, resource)` | 向槽位放入资源 | +| `get_child_identifier(child)` | 获取子资源的标识符 | +| `capacity` | 总槽位数 | +| `sites` | 所有槽位字典 | + +--- + +## 2. 序列化与反序列化 + +### PLR ↔ UniLab 转换 + +| 函数 | 位置 | 方向 | +|------|------|------| +| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab | +| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR | + +### `from_plr_resources` 流程 + +``` +PLR Resource + ↓ build_uuid_mapping (递归生成 UUID) + ↓ resource.serialize() → dict + ↓ resource.serialize_all_state() → states + ↓ resource_plr_inner (递归构建 ResourceDictInstance) +ResourceTreeSet +``` + +关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。 + +### `to_plr_resources` 流程 + +``` +ResourceTreeSet + ↓ collect_node_data (收集 UUID、状态、扩展数据) + ↓ node_to_plr_dict (转为 PLR 字典格式) + ↓ find_subclass(type_name, PLRResource) (查找 PLR 子类) + ↓ sub_cls.deserialize(plr_dict) (反序列化) + ↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展) +PLR Resource +``` + +### Bottle 序列化 + +```python +class Bottle(Well): + def serialize(self) -> dict: + data = super().serialize() + return {**data, "diameter": self.diameter, "height": self.height} + + @classmethod + def deserialize(cls, data: dict, allow_marshal=False): + barcode_data = data.pop("barcode", None) + instance = super().deserialize(data, allow_marshal=allow_marshal) + if barcode_data and isinstance(barcode_data, str): + instance.barcode = barcode_data + return instance +``` + +--- + +## 3. Bioyond 物料同步 + +### 双向转换函数 + +| 函数 | 位置 | 方向 | +|------|------|------| +| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR | +| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond | + +### `resource_bioyond_to_plr` 流程 + +``` +Bioyond 物料列表 + ↓ reverse_type_mapping: {typeName → (model, UUID)} + ↓ 对每个物料: + typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor") + initialize_resource({"name": unique_name, "class": model}) + ↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等) + ↓ 处理 detail (子物料/坐标) + ↓ 按 locationName 放入 deck.warehouses 对应槽位 +PLR 资源列表 +``` + +### `resource_plr_to_bioyond` 流程 + +``` +PLR 资源列表 + ↓ 遍历每个资源: + 载架(capacity > 1): 生成 details 子物料 + 坐标 + 单瓶: 直接映射 + ↓ type_mapping 查找 typeId + ↓ warehouse_mapping 查找位置 UUID + ↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations) +Bioyond 物料列表 +``` + +### BioyondResourceSynchronizer + +工作站通过 `ResourceSynchronizer` 自动同步物料: + +```python +class BioyondResourceSynchronizer(ResourceSynchronizer): + def sync_from_external(self) -> bool: + all_data = [] + all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材 + all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品 + all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂 + unilab_resources = resource_bioyond_to_plr( + all_data, + type_mapping=self.workstation.bioyond_config["material_type_mappings"], + deck=self.workstation.deck + ) + # 更新 deck 上的资源 +``` + +--- + +## 4. 非瓶类资源 + +### ElectrodeSheet(极片) + +路径:`unilabos/resources/battery/electrode_sheet.py` + +```python +class ElectrodeSheet(ResourcePLR): + """片状材料(极片、隔膜、弹片、垫片等)""" + _unilabos_state = { + "diameter": 0.0, + "thickness": 0.0, + "mass": 0.0, + "material_type": "", + "color": "", + "info": "", + } +``` + +工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil` + +### Battery(电池) + +```python +class Battery(Container): + """组装好的电池""" + _unilabos_state = { + "color": "", + "electrolyte_name": "", + "open_circuit_voltage": 0.0, + } +``` + +### Magazine / MagazineHolder(子弹夹) + +```python +class Magazine(ResourceStack): + """子弹夹洞位,可堆叠 ElectrodeSheet""" + # direction, max_sheets + +class MagazineHolder(ItemizedResource): + """多洞位子弹夹""" + # hole_diameter, hole_depth, max_sheets_per_hole +``` + +工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。 + +--- + +## 5. 仓库工厂模式参考 + +### 实际 warehouse 工厂函数示例 + +```python +# 行优先 4x4 仓库 +def bioyond_warehouse_1x4x4(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=4, num_items_y=4, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + layout="row-major", # A01,A02,A03,A04, B01,... + ) + +# 右侧 4x4 仓库(列名偏移) +def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=4, num_items_y=4, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + col_offset=4, # A05,A06,A07,A08 + layout="row-major", + ) + +# 竖向仓库(站内试剂存放) +def bioyond_warehouse_reagent_storage(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=1, num_items_y=2, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + layout="vertical-col-major", + ) + +# 行偏移(F 行开始) +def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=3, num_items_y=5, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=159.0, item_dy=183.0, item_dz=130.0, + row_offset=row_offset, # 0→A行起,5→F行起 + layout="row-major", + ) +``` + +### layout 类型说明 + +| layout | 命名顺序 | 适用场景 | +|--------|---------|---------| +| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 | +| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 | +| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) | + +--- + +## 6. 关键路径 + +| 内容 | 路径 | +|------|------| +| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` | +| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` | +| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` | +| Bioyond 物料转换 | `unilabos/resources/graphio.py` | +| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` | +| 电池资源 | `unilabos/resources/battery/` | +| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` | diff --git a/.cursor/skills/add-workstation/SKILL.md b/.cursor/skills/add-workstation/SKILL.md new file mode 100644 index 00000000..2762d4e4 --- /dev/null +++ b/.cursor/skills/add-workstation/SKILL.md @@ -0,0 +1,500 @@ +--- +name: add-workstation +description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Walks through workstation type selection, sub-device composition, external system integration, driver creation, registry YAML, deck setup, and graph file configuration. Use when the user wants to add/integrate a new workstation, create a workstation driver, configure a station with sub-devices, set up deck and materials, or mentions 工作站/工站/station/workstation. +--- + +# Uni-Lab-OS 工作站接入指南 + +工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统(PLR Deck)和工作流引擎。本指南覆盖从需求分析到验证的全流程。 + +> **前置知识**:工作站接入基于 `docs/ai_guides/add_device.md` 的通用设备接入框架,但有显著差异。阅读本指南前无需先读通用指南。 + +## 第一步:确定工作站类型 + +向用户确认以下信息: + +**Q1: 工作站的业务场景?** + +| 类型 | 基类 | 适用场景 | 示例 | +|------|------|----------|------| +| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(过滤、转移、加热等) | FilterProtocolStation | +| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 系统对接,有专属 API | BioyondStation | +| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件,无外部系统 | CoinCellAssembly | + +**Q2: 工作站英文名称?**(如 `my_reaction_station`) + +**Q3: 与外部系统的交互方式?** + +| 方式 | 适用场景 | 需要的配置 | +|------|----------|-----------| +| 无外部系统 | Protocol 工作站、纯硬件控制 | 无 | +| HTTP API | LIMS/MES 系统(如 Bioyond) | `api_host`, `api_key` | +| Modbus TCP | PLC 控制 | `address`, `port` | +| OPC UA | 工业设备 | `url` | + +**Q4: 子设备组成?** +- 列出所有子设备(如反应器、泵、阀、传感器等) +- 哪些是已有设备类型?哪些需要新增? +- 子设备之间的硬件代理关系(如泵通过串口设备通信) + +**Q5: 物料管理需求?** +- 是否需要 Deck(物料面板)? +- 物料类型(plate、tip_rack、bottle 等) +- 是否需要与外部物料系统同步? + +--- + +## 第二步:理解工作站架构 + +工作站与普通设备的核心差异: + +| 维度 | 普通设备 | 工作站 | +|------|---------|--------| +| 基类 | 无(纯 Python 类) | `WorkstationBase` 或 `ProtocolNode` | +| ROS 节点 | `BaseROS2DeviceNode` | `ROS2WorkstationNode` | +| 状态管理 | `self.data` 字典 | 通常不用 `self.data`,用 `@property` 直接访问 | +| 子设备 | 无 | `children` 列表,通过 `self._children` 访问 | +| 物料 | 无 | `self.deck`(PLR Deck) | +| 图文件角色 | `parent: null` 或 `parent: ""` | `parent: null`,含 `children` 和 `deck` | + +### 继承体系 + +`WorkstationBase` (ABC) → `ProtocolNode` (通用协议) / `BioyondWorkstation` (→ ReactionStation, DispensingStation) / `CoinCellAssemblyWorkstation` (硬件控制) + +### ROS 层 + +`ROS2WorkstationNode` 额外负责:初始化 children 子设备节点、为子设备创建 ActionClient、配置硬件代理、为 protocol_type 创建协议 ActionServer。 + +--- + +## 第三步:创建驱动文件 + +文件路径:`unilabos/devices/workstation//.py` + +### 模板 A:基于外部系统的工作站 + +适用于与 LIMS/MES 等外部系统对接的场景。 + +```python +import logging +from typing import Dict, Any, Optional, List +from pylabrobot.resources import Deck + +from unilabos.devices.workstation.workstation_base import WorkstationBase + +try: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +except ImportError: + ROS2WorkstationNode = None + + +class MyWorkstation(WorkstationBase): + """工作站描述""" + + _ros_node: "ROS2WorkstationNode" + + def __init__( + self, + config: dict = None, + deck: Optional[Deck] = None, + protocol_type: list = None, + **kwargs, + ): + super().__init__(deck=deck, **kwargs) + self.config = config or {} + self.logger = logging.getLogger(f"MyWorkstation") + + # 外部系统连接配置 + self.api_host = self.config.get("api_host", "") + self.api_key = self.config.get("api_key", "") + + # 工作站业务状态(不同于 self.data 模式) + self._status = "Idle" + + def post_init(self, ros_node: "ROS2WorkstationNode") -> None: + super().post_init(ros_node) + # 在这里启动后台服务、连接监控等 + + # ============ 子设备访问 ============ + + def _get_child_device(self, device_id: str): + """通过 ID 获取子设备节点""" + return self._children.get(device_id) + + # ============ 动作方法 ============ + + async def scheduler_start(self, **kwargs) -> Dict[str, Any]: + """启动调度器""" + return {"success": True} + + async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]: + """创建工单""" + return {"success": True} + + # ============ 属性 ============ + + @property + def workflow_sequence(self) -> str: + return "[]" + + @property + def material_info(self) -> str: + return "{}" +``` + +### 模板 B:基于硬件控制的工作站 + +适用于直接与 PLC/硬件通信的场景。 + +```python +import logging +from typing import Dict, Any, Optional +from pylabrobot.resources import Deck + +from unilabos.devices.workstation.workstation_base import WorkstationBase + +try: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +except ImportError: + ROS2WorkstationNode = None + + +class MyHardwareWorkstation(WorkstationBase): + """硬件控制工作站""" + + _ros_node: "ROS2WorkstationNode" + + def __init__( + self, + config: dict = None, + deck: Optional[Deck] = None, + address: str = "192.168.1.100", + port: str = "502", + debug_mode: bool = False, + *args, + **kwargs, + ): + super().__init__(deck=deck, *args, **kwargs) + self.config = config or {} + self.address = address + self.port = int(port) + self.debug_mode = debug_mode + self.logger = logging.getLogger("MyHardwareWorkstation") + + # 初始化通信客户端 + if not debug_mode: + from unilabos.device_comms.modbus_plc.client import ModbusTcpClient + self.client = ModbusTcpClient(host=self.address, port=self.port) + else: + self.client = None + + def post_init(self, ros_node: "ROS2WorkstationNode") -> None: + super().post_init(ros_node) + + # ============ 硬件读写 ============ + + def _read_register(self, name: str): + """读取 Modbus 寄存器""" + if self.debug_mode: + return 0 + # 实际读取逻辑 + pass + + # ============ 动作方法 ============ + + async def start_process(self, **kwargs) -> Dict[str, Any]: + """启动加工流程""" + return {"success": True} + + async def stop_process(self, **kwargs) -> Dict[str, Any]: + """停止加工流程""" + return {"success": True} + + # ============ 属性(从硬件实时读取)============ + + @property + def sys_status(self) -> str: + return str(self._read_register("SYS_STATUS")) +``` + +### 模板 C:Protocol 工作站 + +适用于标准化学操作协议的场景,直接使用 `ProtocolNode`。 + +```python +from typing import List, Optional +from pylabrobot.resources import Resource as PLRResource + +from unilabos.devices.workstation.workstation_base import ProtocolNode + + +class MyProtocolStation(ProtocolNode): + """Protocol 工作站 — 使用标准化学操作协议""" + + def __init__( + self, + protocol_type: List[str], + deck: Optional[PLRResource] = None, + *args, + **kwargs, + ): + super().__init__(protocol_type=protocol_type, deck=deck, *args, **kwargs) +``` + +> Protocol 工作站通常不需要自定义驱动类,直接使用 `ProtocolNode` 并在注册表和图文件中配置 `protocol_type` 即可。 + +--- + +## 第四步:创建子设备驱动(如需要) + +工作站的子设备本身是独立设备。按 `docs/ai_guides/add_device.md` 的标准流程创建。 + +子设备的关键约束: +- 在图文件中 `parent` 指向工作站 ID +- 图文件中在工作站的 `children` 数组里列出 +- 如需硬件代理,在子设备的 `config.hardware_interface.name` 指向通信设备 ID + +--- + +## 第五步:创建注册表 YAML + +路径:`unilabos/registry/devices/.yaml` + +### 最小配置 + +```yaml +my_workstation: + category: + - workstation + class: + module: unilabos.devices.workstation.my_station.my_station:MyWorkstation + type: python +``` + +启动时 `--complete_registry` 自动补全 `status_types` 和 `action_value_mappings`。 + +### 完整配置参考 + +```yaml +my_workstation: + description: "我的工作站" + version: "1.0.0" + category: + - workstation + - my_category + class: + module: unilabos.devices.workstation.my_station.my_station:MyWorkstation + type: python + status_types: + workflow_sequence: String + material_info: String + action_value_mappings: + scheduler_start: + type: UniLabJsonCommandAsync + goal: {} + result: + success: success + create_order: + type: UniLabJsonCommandAsync + goal: + json_str: json_str + result: + success: success + init_param_schema: + config: + type: object + deck: + type: object + protocol_type: + type: array +``` + +### 子设备注册表 + +子设备有独立的注册表文件,需要在 `category` 中包含工作站标识: + +```yaml +my_reactor: + category: + - reactor + - my_workstation + class: + module: unilabos.devices.workstation.my_station.my_reactor:MyReactor + type: python +``` + +--- + +## 第六步:配置 Deck 资源(如需要) + +如果工作站有物料管理需求,需要定义 Deck 类。 + +### 使用已有 Deck 类 + +查看 `unilabos/resources/` 目录下是否有适用的 Deck 类。 + +### 创建自定义 Deck + +在 `unilabos/resources//decks.py` 中定义: + +```python +from pylabrobot.resources import Deck +from pylabrobot.resources.coordinate import Coordinate + + +def MyStation_Deck(name: str = "MyStation_Deck") -> Deck: + deck = Deck(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0) + # 在 deck 上定义子资源位置(carrier、plate 等) + return deck +``` + +在 `unilabos/resources//` 下注册或通过注册表引用。 + +--- + +## 第七步:配置图文件 + +图文件路径:`unilabos/test/experiments/.json` + +### 完整结构 + +```json +{ + "nodes": [ + { + "id": "my_station", + "name": "my_station", + "children": ["my_deck", "sub_device_1", "sub_device_2"], + "parent": null, + "type": "device", + "class": "my_workstation", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "api_host": "http://192.168.1.100:8080", + "api_key": "YOUR_KEY" + }, + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module.decks:MyStation_Deck" + } + }, + "size_x": 2700.0, + "size_y": 1080.0, + "size_z": 1500.0, + "protocol_type": [], + "data": {} + }, + { + "id": "my_deck", + "name": "my_deck", + "children": [], + "parent": "my_station", + "type": "deck", + "class": "MyStation_Deck", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "type": "MyStation_Deck", + "setup": true, + "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} + }, + "data": {} + }, + { + "id": "sub_device_1", + "name": "sub_device_1", + "children": [], + "parent": "my_station", + "type": "device", + "class": "sub_device_registry_name", + "position": {"x": 100, "y": 0, "z": 0}, + "config": {}, + "data": {} + } + ] +} +``` + +### 图文件规则 + +| 字段 | 说明 | +|------|------| +| `id` | 节点唯一标识,与 `children` 数组中的引用一致 | +| `children` | 包含 deck ID 和所有子设备 ID | +| `parent` | 工作站节点为 `null`;子设备/deck 指向工作站 ID | +| `type` | 工作站和子设备为 `"device"`;deck 为 `"deck"` | +| `class` | 对应注册表中的设备名 | +| `deck.data._resource_child_name` | 必须与 deck 节点的 `id` 一致 | +| `deck.data._resource_type` | Deck 工厂函数的完整 Python 路径 | +| `protocol_type` | Protocol 工作站填入协议名列表;否则为 `[]` | +| `config` | 传入驱动 `__init__` 的 `config` 参数 | + +--- + +## 第八步:验证 + +```bash +# 1. 模块可导入 +python -c "from unilabos.devices.workstation.. import " + +# 2. 注册表补全 +unilab -g .json --complete_registry + +# 3. 启动测试 +unilab -g .json +``` + +--- + +## 高级模式 + +实现外部系统对接型工作站时,详见 [reference.md](reference.md):RPC 客户端、HTTP 回调服务、连接监控、Config 结构模式(material_type_mappings / warehouse_mapping / workflow_mappings)、ResourceSynchronizer、update_resource、工作流序列、站间物料转移、post_init 完整模式。 + +--- + +## 关键规则 + +1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.__init__` 需要 `deck` 参数 +2. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用 +3. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接 +4. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()` +5. **子设备在图文件中声明** — 不在驱动代码中创建子设备实例 +6. **`deck` 配置中的 `_resource_child_name` 必须与 deck 节点 ID 一致** +7. **Protocol 工作站优先使用 `ProtocolNode`** — 不需要自定义类 + +--- + +## 工作流清单 + +``` +工作站接入进度: +- [ ] 1. 确定工作站类型(Protocol / 外部系统 / 硬件控制) +- [ ] 2. 确认子设备组成和物料需求 +- [ ] 3. 创建工作站驱动 unilabos/devices/workstation//.py +- [ ] 4. 创建子设备驱动(如需要,按 add_device.md 流程) +- [ ] 5. 创建注册表 unilabos/registry/devices/.yaml +- [ ] 6. 创建/选择 Deck 资源类(如需要) +- [ ] 7. 配置图文件 unilabos/test/experiments/.json +- [ ] 8. 验证:可导入 + 注册表补全 + 启动测试 +``` + +--- + +## 现有工作站参考 + +| 工作站 | 注册表名 | 驱动类 | 类型 | +|--------|----------|--------|------| +| Protocol 通用 | `workstation` | `ProtocolNode` | Protocol | +| Bioyond 反应站 | `reaction_station.bioyond` | `BioyondReactionStation` | 外部系统 | +| Bioyond 配液站 | `bioyond_dispensing_station` | `BioyondDispensingStation` | 外部系统 | +| 纽扣电池组装 | `coincellassemblyworkstation_device` | `CoinCellAssemblyWorkstation` | 硬件控制 | + +### 参考文件路径 + +- 基类: `unilabos/devices/workstation/workstation_base.py` +- Bioyond 基类: `unilabos/devices/workstation/bioyond_studio/station.py` +- 反应站: `unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` +- 配液站: `unilabos/devices/workstation/bioyond_studio/dispensing_station/dispensing_station.py` +- 纽扣电池: `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` +- ROS 节点: `unilabos/ros/nodes/presets/workstation.py` +- 图文件: `unilabos/test/experiments/reaction_station_bioyond.json`, `dispensing_station_bioyond.json` diff --git a/.cursor/skills/add-workstation/reference.md b/.cursor/skills/add-workstation/reference.md new file mode 100644 index 00000000..0c1b9f0d --- /dev/null +++ b/.cursor/skills/add-workstation/reference.md @@ -0,0 +1,371 @@ +# 工作站高级模式参考 + +本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。 +Agent 在需要实现这些功能时按需阅读。 + +--- + +## 1. 外部系统集成模式 + +### 1.1 RPC 客户端 + +与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。 + +```python +from unilabos.device_comms.rpc import BaseRequest + + +class MySystemRPC(BaseRequest): + """外部系统 RPC 客户端""" + + def __init__(self, host: str, api_key: str): + super().__init__(host) + self.api_key = api_key + + def _request(self, endpoint: str, data: dict = None) -> dict: + return self.post( + url=f"{self.host}/api/{endpoint}", + params={ + "apiKey": self.api_key, + "requestTime": self.get_current_time_iso8601(), + "data": data or {}, + }, + ) + + def query_status(self) -> dict: + return self._request("status/query") + + def create_order(self, order_data: dict) -> dict: + return self._request("order/create", order_data) +``` + +参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`) + +### 1.2 HTTP 回调服务 + +接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。 + +```python +from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService + + +class MyWorkstation(WorkstationBase): + def __init__(self, config=None, deck=None, **kwargs): + super().__init__(deck=deck, **kwargs) + self.config = config or {} + http_cfg = self.config.get("http_service_config", {}) + self._http_service_config = { + "host": http_cfg.get("http_service_host", "127.0.0.1"), + "port": http_cfg.get("http_service_port", 8080), + } + self.http_service = None + + def post_init(self, ros_node): + super().post_init(ros_node) + self.http_service = WorkstationHTTPService( + workstation_instance=self, + host=self._http_service_config["host"], + port=self._http_service_config["port"], + ) + self.http_service.start() +``` + +**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发): + +| 端点 | 调用的工作站方法 | +|------|-----------------| +| `/report/step_finish` | `process_step_finish_report(report_request)` | +| `/report/sample_finish` | `process_sample_finish_report(report_request)` | +| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` | +| `/report/material_change` | `process_material_change_report(report_data)` | +| `/report/error_handling` | `handle_external_error(error_data)` | + +实现对应方法即可接收回调: + +```python +def process_step_finish_report(self, report_request) -> Dict[str, Any]: + """处理步骤完成报告""" + step_name = report_request.data.get("stepName") + return {"success": True, "message": f"步骤 {step_name} 已处理"} + +def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]: + """处理订单完成报告""" + order_code = report_request.data.get("orderCode") + return {"success": True} +``` + +参考:`unilabos/devices/workstation/workstation_http_service.py` + +### 1.3 连接监控 + +独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。 + +```python +class ConnectionMonitor: + def __init__(self, workstation, check_interval=30): + self.workstation = workstation + self.check_interval = check_interval + self._running = False + self._thread = None + + def start(self): + self._running = True + self._thread = threading.Thread(target=self._monitor_loop, daemon=True) + self._thread.start() + + def _monitor_loop(self): + while self._running: + try: + # 调用外部系统接口检测连接 + self.workstation.hardware_interface.ping() + status = "online" + except Exception: + status = "offline" + time.sleep(self.check_interval) +``` + +参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`) + +--- + +## 2. Config 结构模式 + +工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式: + +### 2.1 外部系统连接 + +```json +{ + "api_host": "http://192.168.1.100:8080", + "api_key": "YOUR_API_KEY" +} +``` + +### 2.2 HTTP 回调服务 + +```json +{ + "http_service_config": { + "http_service_host": "127.0.0.1", + "http_service_port": 8080 + } +} +``` + +### 2.3 物料类型映射 + +将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。 + +```json +{ + "material_type_mappings": { + "PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"], + "BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."] + } +} +``` + +### 2.4 仓库映射 + +将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。 + +```json +{ + "warehouse_mapping": { + "仓库名": { + "uuid": "warehouse-uuid", + "site_uuids": { + "A01": "site-uuid-A01", + "A02": "site-uuid-A02" + } + } + } +} +``` + +### 2.5 工作流映射 + +将内部工作流名映射到外部系统的工作流 ID。 + +```json +{ + "workflow_mappings": { + "internal_workflow_name": "external-workflow-uuid" + } +} +``` + +### 2.6 物料默认参数 + +```json +{ + "material_default_parameters": { + "NMP": { + "unit": "毫升", + "density": "1.03", + "densityUnit": "g/mL", + "description": "N-甲基吡咯烷酮" + } + } +} +``` + +--- + +## 3. 资源同步机制 + +### 3.1 ResourceSynchronizer + +抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。 + +```python +from unilabos.devices.workstation.workstation_base import ResourceSynchronizer + + +class MyResourceSynchronizer(ResourceSynchronizer): + def __init__(self, workstation, api_client): + super().__init__(workstation) + self.api_client = api_client + + def sync_from_external(self) -> bool: + """从外部系统拉取物料到 deck""" + external_materials = self.api_client.list_materials() + for material in external_materials: + plr_resource = self._convert_to_plr(material) + self.workstation.deck.assign_child_resource(plr_resource, coordinate) + return True + + def sync_to_external(self, plr_resource) -> bool: + """将 deck 中的物料变更推送到外部系统""" + external_data = self._convert_from_plr(plr_resource) + self.api_client.update_material(external_data) + return True + + def handle_external_change(self, change_info) -> bool: + """处理外部系统推送的物料变更""" + return True +``` + +### 3.2 update_resource — 上传资源树到云端 + +将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景: + +```python +# 在 post_init 中上传初始 deck +from unilabos.ros.nodes.base_device_node import ROS2DeviceNode + +ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [self.deck]} +) + +# 在动作方法中更新特定资源 +ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [updated_plate]} +) +``` + +--- + +## 4. 工作流序列管理 + +工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。 + +```python +class MyWorkstation(WorkstationBase): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._workflow_sequence = [] + + @property + def workflow_sequence(self) -> str: + """返回 JSON 字符串,ROS 自动发布""" + import json + return json.dumps(self._workflow_sequence) + + async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]: + """添加工作流到队列""" + self._workflow_sequence.append({ + "name": workflow_name, + "status": "pending", + "created_at": time.time(), + }) + return {"success": True} + + async def clear_workflows(self) -> Dict[str, Any]: + """清空工作流队列""" + self._workflow_sequence = [] + return {"success": True} +``` + +--- + +## 5. 站间物料转移 + +工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。 + +```python +async def transfer_materials_to_another_station( + self, + target_device_id: str, + transfer_groups: list, + **kwargs, +) -> Dict[str, Any]: + """将物料转移到另一个工作站""" + target_node = self._children.get(target_device_id) + if not target_node: + # 通过 ROS 节点查找非子设备的目标站 + pass + + for group in transfer_groups: + resource = self.find_resource_by_name(group["resource_name"]) + # 从本站 deck 移除 + resource.unassign() + # 调用目标站的接收方法 + # ... + + return {"success": True, "transferred": len(transfer_groups)} +``` + +参考:`BioyondDispensingStation.transfer_materials_to_reaction_station` + +--- + +## 6. post_init 完整模式 + +`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。 + +```python +def post_init(self, ros_node): + super().post_init(ros_node) + + # 1. 初始化外部系统客户端(此时 config 已可用) + self.rpc_client = MySystemRPC( + host=self.config.get("api_host"), + api_key=self.config.get("api_key"), + ) + self.hardware_interface = self.rpc_client + + # 2. 启动连接监控 + self.connection_monitor = ConnectionMonitor(self) + self.connection_monitor.start() + + # 3. 启动 HTTP 回调服务 + if hasattr(self, '_http_service_config'): + self.http_service = WorkstationHTTPService( + workstation_instance=self, + host=self._http_service_config["host"], + port=self._http_service_config["port"], + ) + self.http_service.start() + + # 4. 上传 deck 到云端 + ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [self.deck]} + ) + + # 5. 初始化资源同步器(可选) + self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client) +``` diff --git a/.cursor/skills/edit-experiment-graph/SKILL.md b/.cursor/skills/edit-experiment-graph/SKILL.md new file mode 100644 index 00000000..fa236789 --- /dev/null +++ b/.cursor/skills/edit-experiment-graph/SKILL.md @@ -0,0 +1,381 @@ +--- +name: edit-experiment-graph +description: Guide for creating and editing experiment graph files in Uni-Lab-OS (创建/编辑实验组态图). Covers node types, link types, parent-child relationships, deck configuration, and common graph patterns. Use when the user wants to create a graph file, edit an experiment configuration, set up device topology, or mentions 图文件/graph/组态/拓扑/实验图/experiment JSON. +--- + +# 创建/编辑实验组态图 + +实验图(Graph File)定义设备拓扑、物理连接和物料配置。系统启动时加载图文件,初始化所有设备和连接关系。 + +路径:`unilabos/test/experiments/.json` + +--- + +## 第一步:确认需求 + +向用户确认: + +| 信息 | 说明 | +|------|------| +| 场景类型 | 单设备调试 / 多设备联调 / 工作站完整图 | +| 包含的设备 | 设备 ID、注册表 class 名、配置参数 | +| 连接关系 | 物理连接(管道)/ 通信连接(串口)/ 无连接 | +| 父子关系 | 是否有工作站包含子设备 | +| 物料需求 | 是否需要 Deck、容器、试剂瓶 | + +--- + +## 第二步:JSON 顶层结构 + +```json +{ + "nodes": [], + "links": [] +} +``` + +> `links` 也可写作 `edges`,加载时两者等效。 + +--- + +## 第三步:定义 Nodes + +### 节点字段 + +| 字段 | 类型 | 必需 | 默认值 | 说明 | +|------|------|------|--------|------| +| `id` | string | **是** | — | 节点唯一标识,links 和 children 中引用此值 | +| `class` | string | **是** | — | 对应注册表名(设备/资源 YAML 的 key),容器可为 `null` | +| `name` | string | 否 | 同 `id` | 显示名称,缺省时自动用 `id` | +| `type` | string | 否 | `"device"` | 节点类型(见下表),缺省时自动设为 `"device"` | +| `children` | string[] | 否 | `[]` | 子节点 ID 列表 | +| `parent` | string\|null | 否 | `null` | 父节点 ID,顶层设备为 `null` | +| `position` | object | 否 | `{x:0,y:0,z:0}` | 空间坐标 | +| `config` | object | 否 | `{}` | 传给驱动 `__init__` 的参数 | +| `data` | object | 否 | `{}` | 初始运行状态 | +| `size_x/y/z` | float | 否 | — | 节点物理尺寸(工作站节点常用) | + +> 非标准字段(如 `api_host`)会自动移入 `config`。 + +### 节点类型 + +| `type` | 用途 | `class` 要求 | +|--------|------|-------------| +| `device` | 设备(默认) | 注册表中的设备名 | +| `deck` | 工作台面 | Deck 工厂函数/类名 | +| `container` | 容器(烧瓶、反应釜) | `null` 或具体容器类名 | + +### 设备节点模板 + +```json +{ + "id": "my_device", + "name": "我的设备", + "children": [], + "parent": null, + "type": "device", + "class": "registry_device_name", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "port": "/dev/ttyUSB0", + "baudrate": 115200 + }, + "data": { + "status": "Idle" + } +} +``` + +### 容器节点模板 + +容器用于协议系统中表示试剂瓶、反应釜等,`class` 通常为 `null`: + +```json +{ + "id": "flask_DMF", + "name": "DMF试剂瓶", + "children": [], + "parent": "my_station", + "type": "container", + "class": null, + "position": {"x": 200, "y": 500, "z": 0}, + "config": {"max_volume": 1000.0}, + "data": { + "liquid": [{"liquid_type": "DMF", "liquid_volume": 800.0}] + } +} +``` + +### Deck 节点模板 + +```json +{ + "id": "my_deck", + "name": "my_deck", + "children": [], + "parent": "my_station", + "type": "deck", + "class": "MyStation_Deck", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "type": "MyStation_Deck", + "setup": true, + "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} + }, + "data": {} +} +``` + +--- + +## 第四步:定义 Links + +### Link 字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `source` | string | 源节点 ID | +| `target` | string | 目标节点 ID | +| `type` | string | `"physical"` / `"fluid"` / `"communication"` | +| `port` | object | 端口映射 `{source_id: "port_name", target_id: "port_name"}` | + +### 物理/流体连接 + +设备间的管道连接,协议系统用此查找路径: + +```json +{ + "source": "multiway_valve_1", + "target": "flask_DMF", + "type": "fluid", + "port": { + "multiway_valve_1": "2", + "flask_DMF": "outlet" + } +} +``` + +### 通信连接 + +设备间的串口/IO 通信代理,加载时自动将端口信息写入目标设备 config: + +```json +{ + "source": "pump_1", + "target": "serial_device", + "type": "communication", + "port": { + "pump_1": "port", + "serial_device": "port" + } +} +``` + +--- + +## 第五步:父子关系与工作站配置 + +### 工作站 + 子设备 + +工作站节点的 `children` 列出所有子节点 ID,子节点的 `parent` 指向工作站: + +```json +{ + "id": "my_station", + "children": ["my_deck", "pump_1", "valve_1", "reactor_1"], + "parent": null, + "type": "device", + "class": "workstation", + "config": { + "protocol_type": ["PumpTransferProtocol", "CleanProtocol"] + } +} +``` + +### 工作站 + Deck 引用 + +工作站节点中通过 `deck` 字段引用 Deck: + +```json +{ + "id": "my_station", + "children": ["my_deck", "sub_device_1"], + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module.decks:MyDeck" + } + } +} +``` + +**关键约束:** +- `_resource_child_name` 必须与 Deck 节点的 `id` 一致 +- `_resource_type` 为 Deck 类/工厂函数的完整 Python 路径 + +--- + +## 常见图模式 + +### 模式 A:单设备调试 + +最简形式,一个设备节点,无连接: + +```json +{ + "nodes": [ + { + "id": "my_device", + "name": "my_device", + "children": [], + "parent": null, + "type": "device", + "class": "motor.zdt_x42", + "position": {"x": 0, "y": 0, "z": 0}, + "config": {"port": "/dev/ttyUSB0", "baudrate": 115200}, + "data": {"status": "idle"} + } + ], + "links": [] +} +``` + +### 模式 B:Protocol 工作站(泵+阀+容器) + +工作站配合泵、阀、容器和物理连接,用于协议编译: + +```json +{ + "nodes": [ + { + "id": "station", "name": "协议工作站", + "class": "workstation", "type": "device", "parent": null, + "children": ["pump", "valve", "flask_solvent", "reactor", "waste"], + "config": {"protocol_type": ["PumpTransferProtocol"]} + }, + {"id": "pump", "name": "转移泵", "class": "virtual_transfer_pump", + "type": "device", "parent": "station", + "config": {"port": "VIRTUAL", "max_volume": 25.0}, + "data": {"status": "Idle", "position": 0.0, "valve_position": "0"}}, + {"id": "valve", "name": "多通阀", "class": "virtual_multiway_valve", + "type": "device", "parent": "station", + "config": {"port": "VIRTUAL", "positions": 8}}, + {"id": "flask_solvent", "name": "溶剂瓶", "type": "container", + "class": null, "parent": "station", + "config": {"max_volume": 1000.0}, + "data": {"liquid": [{"liquid_type": "DMF", "liquid_volume": 500}]}}, + {"id": "reactor", "name": "反应器", "type": "container", + "class": null, "parent": "station"}, + {"id": "waste", "name": "废液瓶", "type": "container", + "class": null, "parent": "station"} + ], + "links": [ + {"source": "pump", "target": "valve", "type": "fluid", + "port": {"pump": "transferpump", "valve": "transferpump"}}, + {"source": "valve", "target": "flask_solvent", "type": "fluid", + "port": {"valve": "1", "flask_solvent": "outlet"}}, + {"source": "valve", "target": "reactor", "type": "fluid", + "port": {"valve": "2", "reactor": "inlet"}}, + {"source": "valve", "target": "waste", "type": "fluid", + "port": {"valve": "3", "waste": "inlet"}} + ] +} +``` + +### 模式 C:外部系统工作站 + Deck + +```json +{ + "nodes": [ + { + "id": "bioyond_station", "class": "reaction_station.bioyond", + "parent": null, "children": ["bioyond_deck"], + "config": { + "api_host": "http://192.168.1.100:8080", + "api_key": "YOUR_KEY", + "material_type_mappings": {}, + "warehouse_mapping": {} + }, + "deck": { + "data": { + "_resource_child_name": "bioyond_deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" + } + } + }, + { + "id": "bioyond_deck", "class": "BIOYOND_PolymerReactionStation_Deck", + "parent": "bioyond_station", "type": "deck", + "config": {"type": "BIOYOND_PolymerReactionStation_Deck", "setup": true} + } + ], + "links": [] +} +``` + +### 模式 D:通信代理(串口设备) + +泵通过串口设备通信,使用 `communication` 类型的 link。加载时系统会自动将串口端口信息写入泵的 `config`: + +```json +{ + "nodes": [ + {"id": "station", "name": "工作站", "type": "device", + "class": "workstation", "parent": null, + "children": ["serial_1", "pump_1"]}, + {"id": "serial_1", "name": "串口", "type": "device", + "class": "serial", "parent": "station", + "config": {"port": "COM7", "baudrate": 9600}}, + {"id": "pump_1", "name": "注射泵", "type": "device", + "class": "syringe_pump_with_valve.runze.SY03B-T08", "parent": "station"} + ], + "links": [ + {"source": "pump_1", "target": "serial_1", "type": "communication", + "port": {"pump_1": "port", "serial_1": "port"}} + ] +} +``` + +--- + +## 验证 + +```bash +# 启动测试 +unilab -g unilabos/test/experiments/.json --complete_registry + +# 仅检查注册表 +python -m unilabos --check_mode --skip_env_check +``` + +--- + +## 高级模式 + +处理复杂图文件时,详见 [reference.md](reference.md):ResourceDict 完整字段 schema、Pose 标准化规则、Handle 验证机制、GraphML 格式支持、外部系统工作站完整 config 结构。 + +--- + +## 常见错误 + +| 错误 | 原因 | 修复 | +|------|------|------| +| `class` 找不到 | 注册表中无此设备名 | 在 `unilabos/registry/devices/` 或 `resources/` 中搜索正确名称 | +| children/parent 不一致 | 子节点 `parent` 与父节点 `children` 不匹配 | 确保双向一致 | +| `_resource_child_name` 不匹配 | Deck 引用名与 Deck 节点 `id` 不同 | 保持一致 | +| Link 端口错误 | `port` 中的 key 不是 source/target 的 `id` | key 必须是对应节点的 `id` | +| 重复 UUID | 多个节点有相同 `uuid` | 删除或修改 UUID | + +--- + +## 参考路径 + +| 内容 | 路径 | +|------|------| +| 图文件目录 | `unilabos/test/experiments/` | +| 协议测试站 | `unilabos/test/experiments/Protocol_Test_Station/` | +| 图加载代码 | `unilabos/resources/graphio.py` | +| 节点模型 | `unilabos/resources/resource_tracker.py` | +| 设备注册表 | `unilabos/registry/devices/` | +| 资源注册表 | `unilabos/registry/resources/` | +| 用户文档 | `docs/user_guide/graph_files.md` | diff --git a/.cursor/skills/edit-experiment-graph/reference.md b/.cursor/skills/edit-experiment-graph/reference.md new file mode 100644 index 00000000..8582acd4 --- /dev/null +++ b/.cursor/skills/edit-experiment-graph/reference.md @@ -0,0 +1,255 @@ +# 实验图高级参考 + +本文件是 SKILL.md 的补充,包含 ResourceDict 完整 schema、Handle 验证、GraphML 格式、Pose 标准化规则和复杂图文件结构。Agent 在需要处理这些场景时按需阅读。 + +--- + +## 1. ResourceDict 完整字段 + +`unilabos/resources/resource_tracker.py` 中定义的节点数据模型: + +| 字段 | 类型 | 别名 | 说明 | +|------|------|------|------| +| `id` | `str` | — | 节点唯一标识 | +| `uuid` | `str` | — | 全局唯一标识 | +| `name` | `str` | — | 显示名称 | +| `description` | `str` | — | 描述(默认 `""` ) | +| `resource_schema` | `Dict[str, Any]` | `schema` | 资源 schema | +| `model` | `Dict[str, Any]` | — | 3D 模型信息 | +| `icon` | `str` | — | 图标(默认 `""` ) | +| `parent_uuid` | `Optional[str]` | — | 父节点 UUID | +| `parent` | `Optional[ResourceDict]` | — | 父节点引用(序列化时 exclude) | +| `type` | `Union[Literal["device"], str]` | — | 节点类型 | +| `klass` | `str` | `class` | 注册表类名 | +| `pose` | `ResourceDictPosition` | — | 位姿信息 | +| `config` | `Dict[str, Any]` | — | 配置参数 | +| `data` | `Dict[str, Any]` | — | 运行时数据 | +| `extra` | `Dict[str, Any]` | — | 扩展数据 | + +### Pose 完整结构(ResourceDictPosition) + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `size` | `{width, height, depth}` | `{0,0,0}` | 节点尺寸 | +| `scale` | `{x, y, z}` | `{1,1,1}` | 缩放比例 | +| `layout` | `"2d"/"x-y"/"z-y"/"x-z"` | `"x-y"` | 布局方向 | +| `position` | `{x, y, z}` | `{0,0,0}` | 2D 位置 | +| `position3d` | `{x, y, z}` | `{0,0,0}` | 3D 位置 | +| `rotation` | `{x, y, z}` | `{0,0,0}` | 旋转角度 | +| `cross_section_type` | `"rectangle"/"circle"/"rounded_rectangle"` | `"rectangle"` | 横截面形状 | + +--- + +## 2. Position / Pose 标准化规则 + +图文件中的 `position` 有多种写法,加载时自动标准化。 + +### 输入格式兼容 + +```json +// 格式 A: 直接 {x, y, z}(最常用) +"position": {"x": 100, "y": 200, "z": 0} + +// 格式 B: 嵌套 position +"position": {"position": {"x": 100, "y": 200, "z": 0}} + +// 格式 C: 使用 pose 字段 +"pose": {"position": {"x": 100, "y": 200, "z": 0}} + +// 格式 D: 顶层 x, y, z(无 position 字段) +"x": 100, "y": 200, "z": 0 +``` + +### 标准化流程 + +1. **graphio.py `canonicalize_nodes_data`**:若 `position` 不是 dict,从节点顶层提取 `x/y/z` 填入 `pose.position` +2. **resource_tracker.py `get_resource_instance_from_dict`**:若 `position.x` 存在(旧格式),转为 `{"position": {"x":..., "y":..., "z":...}}` +3. `pose.size` 从 `config.size_x/size_y/size_z` 自动填充 + +--- + +## 3. Handle 验证 + +启动时系统验证 link 中的 `sourceHandle` / `targetHandle` 是否在注册表的 `handles` 中定义。 + +```python +# unilabos/app/main.py (约 449-481 行) +source_handler_keys = [ + h["handler_key"] for h in materials[source_node.klass]["handles"] + if h["io_type"] == "source" +] +target_handler_keys = [ + h["handler_key"] for h in materials[target_node.klass]["handles"] + if h["io_type"] == "target" +] +if source_handle not in source_handler_keys: + print_status(f"节点 {source_node.id} 的source端点 {source_handle} 不存在", "error") + resource_edge_info.pop(...) # 移除非法 link +``` + +**Handle 定义在注册表 YAML 中:** + +```yaml +my_device: + handles: + - handler_key: access + io_type: target + data_type: fluid + side: NORTH + label: access +``` + +> 大多数简单设备不定义 handles,此验证仅对有 `sourceHandle`/`targetHandle` 的 link 生效。 + +--- + +## 4. GraphML 格式支持 + +除 JSON 外,系统也支持 GraphML 格式(`unilabos/resources/graphio.py::read_graphml`)。 + +### 与 JSON 的关键差异 + +| 特性 | JSON | GraphML | +|------|------|---------| +| 父子关系 | `parent`/`children` 字段 | `::` 分隔的节点 ID(如 `station::pump_1`) | +| 加载后 | 直接解析 | 先 `nx.read_graphml` 再转 JSON 格式 | +| 输出 | 不生成副本 | 自动生成等价的 `.json` 文件 | + +### GraphML 转换流程 + +``` +nx.read_graphml(file) + ↓ 用 label 重映射节点名 + ↓ 从 "::" 推断 parent_relation +nx.relabel_nodes + nx.node_link_data + ↓ canonicalize_nodes_data + canonicalize_links_ports + ↓ 写出等价 JSON 文件 +physical_setup_graph + handle_communications +``` + +--- + +## 5. 复杂图文件结构示例 + +### 外部系统工作站完整 config + +以 `reaction_station_bioyond.json` 为例,工作站 `config` 中的关键字段: + +```json +{ + "config": { + "api_key": "DE9BDDA0", + "api_host": "http://172.21.103.36:45388", + + "workflow_mappings": { + "scheduler_start": {"workflow": "start", "params": {}}, + "create_order": {"workflow": "create_order", "params": {}} + }, + + "material_type_mappings": { + "BIOYOND_PolymerStation_Reactor": ["反应器", "type-uuid-here"], + "BIOYOND_PolymerStation_1BottleCarrier": ["试剂瓶", "type-uuid-here"] + }, + + "warehouse_mapping": { + "堆栈1左": { + "uuid": "warehouse-uuid-here", + "site_uuids": { + "A01": "site-uuid-1", + "A02": "site-uuid-2" + } + } + }, + + "http_service_config": { + "enabled": true, + "host": "0.0.0.0", + "port": 45399, + "routes": ["/callback/workflow", "/callback/material"] + }, + + "deck": { + "data": { + "_resource_child_name": "Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" + } + }, + + "size_x": 2700.0, + "size_y": 1080.0, + "size_z": 2500.0, + "protocol_type": [], + "data": {} + } +} +``` + +### 子设备 Reactor 节点 + +```json +{ + "id": "reactor_1", + "name": "reactor_1", + "parent": "reaction_station_bioyond", + "type": "device", + "class": "bioyond_reactor", + "position": {"x": 1150, "y": 300, "z": 0}, + "config": { + "reactor_index": 0, + "bioyond_workflow_key": "reactor_1" + }, + "data": {} +} +``` + +### Deck 节点 + +```json +{ + "id": "Bioyond_Deck", + "name": "Bioyond_Deck", + "parent": "reaction_station_bioyond", + "type": "deck", + "class": "BIOYOND_PolymerReactionStation_Deck", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "type": "BIOYOND_PolymerReactionStation_Deck", + "setup": true, + "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"} + }, + "data": {} +} +``` + +--- + +## 6. Link 端口标准化 + +`graphio.py::canonicalize_links_ports` 处理 `port` 字段的多种格式: + +```python +# 输入: 字符串格式 "(A,B)" +"port": "(pump_1, valve_1)" +# 输出: 字典格式 +"port": {"source_id": "pump_1", "target_id": "valve_1"} + +# 输入: 已是字典 +"port": {"pump_1": "port", "serial_1": "port"} +# 保持不变 + +# 输入: 无 port 字段 +# 自动补充空 port +``` + +--- + +## 7. 关键路径 + +| 内容 | 路径 | +|------|------| +| ResourceDict 模型 | `unilabos/resources/resource_tracker.py` | +| 图加载 + 标准化 | `unilabos/resources/graphio.py` | +| Handle 验证 | `unilabos/app/main.py` (449-481 行) | +| 反应站图文件 | `unilabos/test/experiments/reaction_station_bioyond.json` | +| 配液站图文件 | `unilabos/test/experiments/dispensing_station_bioyond.json` | +| 用户文档 | `docs/user_guide/graph_files.md` | diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..6cd38be8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +## 设备接入 + +当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`。 +该指南包含完整的模板和已有设备接口参考。 + +## 关键规则 + +- 动作方法的参数名是接口契约,不可重命名 +- `status` 字符串必须与同类已有设备一致 +- `self.data` 必须在 `__init__` 中预填充所有属性字段 +- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..049ee6f8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Uni-Lab-OS AI Agent 指南 + +## 设备接入 + +当用户要求添加/接入新设备时,读取 `docs/ai_guides/add_device.md` 并按其流程执行。 +该指南完全自包含,包含物模型模板、现有设备接口快照、常见错误和验证清单。 + +## 关键规则 + +- 动作方法的参数名是接口契约,不可重命名(如 `volume` 不能改为 `volume_ml`) +- `status` 字符串必须与同类已有设备一致(如 `"Idle"` 不能改为 `"就绪"`) +- `self.data` 必须在 `__init__` 中预填充所有属性字段 +- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()` + +## 项目结构 + +- 设备驱动:`unilabos/devices//.py` +- 设备注册表:`unilabos/registry/devices/.yaml` +- 实验图文件:`unilabos/test/experiments/*.json` +- 人类开发文档:`docs/developer_guide/` +- AI 专用指南:`docs/ai_guides/` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..235d4c3b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# Uni-Lab-OS + +## 设备接入 + +读取 `docs/ai_guides/add_device.md` 获取完整的自包含指南。 +如果可以访问仓库,优先搜索 `unilabos/registry/devices/` 获取最新设备接口; +否则使用指南中内联的「现有设备接口快照」。 + +## 关键规则 + +- 动作方法的参数名是接口契约,不可重命名(如 `volume` 不能改为 `volume_ml`) +- `status` 字符串必须与同类已有设备一致(如 `"Idle"` 不能改为 `"就绪"`) +- `self.data` 必须在 `__init__` 中预填充所有属性字段 +- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()` 和 `asyncio.sleep()` diff --git a/docs/ai_guides/add_device.md b/docs/ai_guides/add_device.md new file mode 100644 index 00000000..b5ca25c4 --- /dev/null +++ b/docs/ai_guides/add_device.md @@ -0,0 +1,1100 @@ +# Uni-Lab-OS 设备接入指南(AI 专用·自包含版) + +> **本文件是完全自包含的。** 即使你无法访问 Uni-Lab-OS 仓库,也能根据本指南正确生成设备驱动。 +> 如果你能访问仓库,建议搜索 `unilabos/registry/devices/` 目录获取最新的已有设备接口。 +> 最新版本也可通过 GitHub 获取:https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +端到端向导,通过**设备类别(物模型)** 和 **通信协议** 两个维度引导设备接入。 + +--- + +## 第一步:选择设备类别(物模型) + +每种设备类别有标准的属性和动作接口。向用户确认以下信息: + +**Q1: 设备属于哪个类别?** + +| 类别 ID | 说明 | 标准属性 | 标准动作 | +|---|---|---|---| +| `temperature` | 加热/冷却/温控 | `temp`, `temp_target`, `status` | `set_temperature`, `stop` | +| `pump_and_valve` | 泵、阀门、注射器 | 见下方子类型表 | 见下方子类型表 | +| `motor` | 电机、步进马达 | `position`, `status` | `enable`, `move_position`, `move_speed`, `stop` | +| `heaterstirrer` | 加热搅拌一体机 | `temp`, `stir_speed`, `status` | `set_temperature`, `stir`, `stop` | +| `balance` | 天平/称重 | `weight`, `unit`, `status` | `tare`, `read_weight` | +| `sensor` | 传感器(液位/温度/...) | `value`, `level`, `status` | `read_value`, `set_threshold` | +| `liquid_handling` | 液体处理机器人 | `status`, `deck_state` | `transfer_liquid`, `aspirate`, `dispense` | +| `robot_arm` | 机械臂 | `arm_pose`, `arm_status` | `moveit_task`, `pick_and_place` | +| `workstation` | 工作站(组合设备) | `workflow_sequence`, `material_info` | `create_order`, `scheduler_start`/`stop` | +| `virtual` | 虚拟/模拟设备 | 按模拟的真实设备定义 | 按模拟的真实设备定义 | +| `custom` | 不属于以上任何类别 | 用户自定义 | 用户自定义 | + +**pump_and_valve 子类型:** 该类别包含差异较大的子类型,下表仅列出**最小通用接口**。具体项目中可能有更多属性和动作,由第四步(对齐同类设备接口)动态发现。 + +| 子类型 | 最小通用属性 | 最小通用动作 | 单位约定 | +|---|---|---|---| +| 注射泵(syringe pump) | `status`, `valve_position`, `position`(mL) | `initialize`, `set_valve_position`, `set_position`(mL), `pull_plunger`(mL), `push_plunger`(mL), `stop_operation` | 体积=mL, 速度=mL/s | +| 电磁阀(solenoid valve) | `status`, `valve_position` | `open`, `close`, `set_valve_position` | — | +| 蠕动泵(peristaltic pump) | `status`, `speed` | `start`, `stop`, `set_speed` | 流速=mL/min | + +**单位约定(重要):** 设备对外暴露的属性和动作参数**必须使用用户友好的物理单位**,不能使用原始步数或寄存器值。驱动内部负责在物理单位和硬件原始值之间转换。 + +| 类别 | 位置/体积 | 速度 | 温度 | 其他 | +|---|---|---|---|---| +| pump_and_valve (注射泵) | **mL** | **mL/s** | — | — | +| pump_and_valve (蠕动泵) | — | **mL/min** | — | — | +| motor | **mm** 或 **度** | **mm/s** 或 **RPM** | — | — | +| temperature | — | — | **°C** | — | +| balance | **g** 或 **mg** | — | — | — | +| sensor | 按传感器物理量定 | — | — | — | + +**Q2: 设备英文名称?** (如 `my_heater`,用于类名和文件名) + +--- + +## 第二步:选择通信协议 + +**Q3: 设备使用什么通信协议?** + +| 协议 | config 参数 | 依赖包 | UniLab 现有抽象 | +|---|---|---|---| +| **Serial (RS232/RS485)** | `port`, `baudrate` | `pyserial` | 直接使用 `serial.Serial` | +| **Modbus RTU** | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/`(RTUClient) | +| **Modbus TCP** | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/`(TCPClient) | +| **TCP Socket** | `host`, `port` | stdlib | 直接使用 `socket` | +| **HTTP API** | `url`, `token` | `requests` | `device_comms/rpc.py`(BaseRequest) | +| **OPC UA** | `url` | `opcua` | `device_comms/opcua_client/`(OpcUaClient) | +| **无通信(虚拟)** | 无 | 无 | 无 | + +--- + +## 第三步:收集指令协议(关键) + +物模型定义了设备"应该做什么",通信协议定义了"用什么方式通信",但**具体发什么指令**是硬件厂商私有的,AI 无法凭空生成。必须从以下来源获取: + +**Q4: 指令协议的信息来源?** + +| 来源 | AI 处理方式 | 示例 | +|---|---|---| +| **现成 SDK/驱动代码** | 读取代码,提取指令逻辑,包装进 UniLab 框架 | 用户提供 `.py` 文件或 pip 包名 | +| **协议文档/手册** | 读取文档(PDF/图片/文本),解析指令格式 | 用户提供通信协议手册 | +| **用户口述** | 按描述实现指令编解码 | "设温指令是 `01 06 00 0B` + 温度值 + CRC" | +| **标准协议** | 直接使用标准实现 | 标准 Modbus 寄存器表、SCPI 指令集 | +| **HTTP API 文档** | 读取 API 文档,映射到动作方法 | Swagger/OpenAPI 文档 | + +**根据来源执行对应流程:** + +### 场景 A:用户提供了现成 SDK 或驱动代码 + +1. 读取用户提供的驱动代码 +2. 分析其中的通信逻辑:初始化、指令编码、响应解码 +3. 将核心逻辑包装进 UniLab 设备类框架(加入 `self.data` 状态管理、`@property` 属性等) + +### 场景 B:用户提供了协议文档/手册 + +1. 读取文档(支持 PDF、图片、文本) +2. 从文档中提取: + - **指令格式**(文本型 `SET_TEMP 100\r\n`、二进制帧、Modbus 寄存器地址等) + - **响应格式**(如何解析返回数据) + - **寄存器/地址映射表**(哪个地址对应什么功能) +3. 实现指令编解码方法 + +### 场景 C:用户口头描述指令 + +逐个确认每个物模型动作对应的具体指令: + +``` +对于第一步选定的每个标准动作,询问: +- set_temperature → 硬件指令是什么?(如 Modbus 写寄存器 0x000B) +- read_temperature → 硬件指令是什么?(如 发送 0xfe 0xA2 0x00 0x00) +- stop → 硬件指令是什么? +``` + +### 场景 D:虚拟设备(无实际通信) + +跳过此步骤,动作方法中直接模拟行为(修改 `self.data`,用 `sleep` 模拟耗时)。 + +--- + +## 第四步:对齐同类设备接口(强制) + +第一步给出的是**最小通用接口**。本步骤在此基础上,对照仓库现有注册表,**补充**额外的属性和动作,确保新驱动能无缝替换同类设备。 + +> **此步骤是强制性的,不可跳过。** 跳过此步会导致参数名不匹配、status 字符串不一致、缺失属性等问题,使设备无法在工作流中正确运行。 + +**执行步骤:** + +1. 查阅下方「现有设备接口快照」章节,找到同类别的已有设备接口。如果你能访问仓库,建议直接搜索 `unilabos/registry/devices/` 目录获取最新版本。 + +2. 提取已有设备的**额外接口**(超出第一步最小通用接口的部分): + - **status_types** — 是否有额外属性? + - **action_value_mappings** — 是否有额外动作?**逐个记录参数名和类型** + - **status 字符串** — 已有设备用的是什么值?(如 `"Idle"` / `"Busy"` 还是中文?) + - **单位** — 确认单位是否与第一步约定一致 + +3. 对齐决策: + - 新驱动**必须实现**第一步的最小通用接口 + - 如果已有设备有额外属性/动作,**判断新硬件是否支持**: + - 硬件支持 → **必须实现**(保持接口一致) + - 硬件不支持 → 可提供合理的默认值或空实现,但属性必须存在 + - **参数名必须与已有设备完全一致**(这是最常出错的地方) + - **status 字符串值必须与已有设备一致** + - 可以**增加**新的属性和动作,但最小通用接口不能缺少 + +4. 如果同类别下没有已有设备,跳过对齐,按第一步的最小通用接口即可。 + +**对齐验证清单(完成第五步后必须逐项确认):** + +``` +- [ ] 所有动作方法的参数名与已有设备完全一致(如 volume 而非 volume_ml) +- [ ] status 属性返回的字符串值与已有设备一致(如 "Idle" 而非 "就绪") +- [ ] 已有设备的所有 status_types 字段在新驱动中都有对应 @property +- [ ] 已有设备的所有非 auto- 前缀的 action 在新驱动中都有对应方法 +- [ ] self.data 在 __init__ 中已预填充所有属性字段的默认值 +- [ ] 串口/二进制协议的响应解析先定位帧起始标记,不使用硬编码索引 +``` + +--- + +## 第五步:创建设备驱动文件 + +文件路径:`unilabos/devices//.py` + +### 核心结构 + +设备类 = 物模型标准接口 + 通信协议层 + 具体指令编解码: + +```python +import logging +import time as time_module +from typing import Dict, Any + +try: + from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +except ImportError: + BaseROS2DeviceNode = None + + +class MyDevice: + """设备描述""" + + _ros_node: "BaseROS2DeviceNode" + + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): + if device_id is None and 'id' in kwargs: + device_id = kwargs.pop('id') + if config is None and 'config' in kwargs: + config = kwargs.pop('config') + + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + + # self.data 必须预填充所有 @property 对应的字段 + # status 字符串必须与同类已有设备一致(查看第四步) + self.data = { + "status": "Idle", + # "其他属性": 默认值, ← 每个 @property 都要有对应的键 + } + + # --- 通信层初始化(按第二步选择的协议填入)--- + # self.ser = serial.Serial(...) + # self.client = ModbusTcpClient(...) + + def post_init(self, ros_node: "BaseROS2DeviceNode"): + self._ros_node = ros_node + + async def initialize(self) -> bool: + self.data.update({"status": "Idle"}) + return True + + async def cleanup(self) -> bool: + self.data.update({"status": "Offline"}) + return True + + # --- 通信辅助方法(按第三步收集的指令协议实现)--- + # def _send_command(self, cmd: str) -> str: ... + + # --- 物模型标准动作(调用通信辅助方法发送实际指令)--- + # async def set_temperature(self, temp: float, **kwargs) -> bool: ... + + # --- 物模型标准属性 --- + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### 关键规则 + +1. **参数类型转换** — 动作参数可能以字符串传入,必须显式 `float()`/`int()` 转换 +2. **异步等待** — 使用 `await self._ros_node.sleep()`,**禁止** `asyncio.sleep()`,也**禁止** `time.sleep()`(会阻塞事件循环) +3. **状态存储** — 用 `self.data` 字典存储,`@property` 读取并自动广播 +4. **进度反馈** — 长操作需循环更新 `self.data["status"]` 和 `remaining_time` +5. **返回值** — 返回 `bool` 或 `Dict[str, Any]`(含 `success` 字段),会显示在前端 + +### 禁止事项(严格遵守) + +以下是导致设备无法接入的常见错误,**必须逐条检查**: + +1. **禁止重命名模板参数** — 模板中的方法参数名(如 `volume`、`position`、`max_velocity`)是接口契约,框架通过参数名分派调用。**绝对不能**加后缀(如 `volume_ml`)、改名(如 `speed_ml_s`)或用其他"更可读"的名字替代。单位信息写在 docstring 中,不写在参数名中。 +2. **status 字符串必须与同类已有设备一致** — 如果已有设备使用英文(如 pump_and_valve 的 `"Idle"` / `"Busy"`),新驱动**必须使用相同的字符串**,不能改为中文。上层代码可能通过 `status == "Idle"` 来判断状态。 +3. **`self.data` 必须在 `__init__` 中预填充所有属性字段** — 不能用空字典 `{}`。框架在 `initialize()` 之前就可能读取属性值。每个 `@property` 对应的键都必须有初始值。 +4. **禁止跳过第四步** — 对齐同类设备接口是强制步骤,不是可选步骤。缺失的属性和动作会导致设备在工作流中不可互换。 +5. **禁止用硬编码索引解析串口响应** — RS-485 半双工总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 `/`、`0xFE`),再用相对偏移解析。否则所有解析方法(错误码、忙闲判断、数据提取)会同时出错,且部分可能歪打正着,造成隐蔽 bug。 + +### 特殊参数类型 + +需要前端资源/设备选择器时: + +```python +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot + +def transfer(self, source: ResourceSlot, target: ResourceSlot, volume: float) -> Dict[str, Any]: + return {"success": True, "volume": volume} +``` + +| Python 类型 | 前端效果 | +|---|---| +| `ResourceSlot` | 单选资源下拉框 | +| `List[ResourceSlot]` | 多选资源下拉框 | +| `DeviceSlot` | 单选设备下拉框 | +| `List[DeviceSlot]` | 多选设备下拉框 | + +### 设备架构分支 + +| 场景 | 基类 | 说明 | +|---|---|---| +| 简单设备 | 无基类(纯 Python 类) | 大多数情况 | +| 工作站 | `WorkstationBase` | 组合多个子设备,有 Deck | +| 液体处理 | `LiquidHandlerAbstract` | PyLabRobot 集成 | +| Modbus 设备 | 可用 `device_comms/modbus_plc/` | 节点注册 + 工作流 | +| OPC UA 设备 | 可用 `device_comms/opcua_client/` | 节点发现 + CSV 配置 | + +--- + +## 第六步:创建注册表 YAML + +在 `unilabos/registry/devices/` 下创建。 + +### 最小配置(推荐) + +```yaml +my_device: + class: + module: unilabos.devices..:MyDevice + type: python +``` + +启动时 `--complete_registry` 自动生成 `status_types`、`action_value_mappings` 等全部字段。 + +### 手动补充(可选) + +```yaml +my_device: + category: + - temperature + description: "我的温控设备" + class: + module: unilabos.devices.temperature.my_heater:MyHeater + type: python +``` + +### 完整 YAML 结构参考 + +```yaml +my_device: + description: "设备描述" + version: "1.0.0" + category: [my_category] + icon: "" + handles: [] + class: + module: unilabos.devices.my_category.my_device:MyDevice + type: python + status_types: + status: String # str → String + temp: Float64 # float → Float64 + is_running: Bool # bool → Bool + position: Int64 # int → Int64 + action_value_mappings: + my_action: + type: UniLabJsonCommandAsync # 或 UniLabJsonCommand + goal: + param1: param1 + result: + success: success + goal_default: + param1: 0.0 + handles: {} + placeholder_keys: {} + schema: + title: my_action参数 + type: object + properties: + goal: + type: object + properties: + param1: + type: number + required: [param1] + required: [goal] +``` + +### Python → ROS 类型映射 + +| Python | ROS | YAML `status_types` | +|---|---|---| +| `str` | `std_msgs/String` | `String` | +| `bool` | `std_msgs/Bool` | `Bool` | +| `int` | `std_msgs/Int64` | `Int64` | +| `float` | `std_msgs/Float64` | `Float64` | +| `list`/`dict` | `std_msgs/String`(JSON 序列化) | `String` | + +--- + +## 第七步:配置图文件 + +在实验图文件(JSON)中添加设备节点: + +```json +{ + "id": "my_device_1", + "name": "我的设备", + "children": [], + "parent": null, + "type": "device", + "class": "my_device", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "port": "/dev/ttyUSB0", + "baudrate": 9600 + }, + "data": {} +} +``` + +`config` 中的参数对应通信协议所需的连接信息,直接传入 `__init__` 的 `config` 字典。 + +--- + +## 第八步:验证 + +```bash +# 1. 模块可导入 +python -c "from unilabos.devices.. import " + +# 2. 注册表补全(可选) +unilab -g .json --complete_registry + +# 3. 启动测试 +unilab -g .json +``` + +--- + +## 工作流清单 + +``` +设备接入进度: +- [ ] 1. 确定设备类别(物模型)+ 单位约定 +- [ ] 2. 确定通信协议 +- [ ] 3. 收集指令协议(SDK/文档/口述) +- [ ] 4. 对齐同类设备接口(对照快照或搜索注册表) +- [ ] 5. 创建驱动 unilabos/devices//.py +- [ ] 6. 创建注册表 unilabos/registry/devices/.yaml +- [ ] 7. 配置图文件(如需要) +- [ ] 8. 验证可导入 + 启动测试 +``` + +--- + +## 现有设备接口快照 + +> 以下是仓库中已有设备的接口定义,用于第四步对齐。 +> 如果你能访问仓库,建议搜索 `unilabos/registry/devices/` 获取最新版本。 +> 最新版本也可通过 GitHub 获取: +> https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +### pump_and_valve — 注射泵子类型 + +已有设备:`syringe_pump_with_valve.runze.SY03B-T06` / `SY03B-T08` +驱动类:`unilabos.devices.pump_and_valve.runze_backbone:RunzeSyringePump` + +**status_types(属性):** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | `"Idle"` / `"Busy"` | +| `valve_position` | `str` | 阀门位置 | +| `position` | `float` | 当前体积 (mL) | +| `max_velocity` | `float` | 最大速度 (mL/s) | +| `mode` | `int` | 运行模式 | +| `plunger_position` | `String` | 活塞位置 | +| `velocity_grade` | `String` | 速度档位 | +| `velocity_init` | `String` | 初始速度 | +| `velocity_end` | `String` | 终止速度 | + +**关键动作方法签名(参数名不可修改):** + +```python +def initialize(self) +def set_valve_position(self, position) # 参数名必须是 position +def set_position(self, position: float, max_velocity: float = None) +def pull_plunger(self, volume: float) # 参数名必须是 volume +def push_plunger(self, volume: float) # 参数名必须是 volume +def set_max_velocity(self, velocity: float) +def set_velocity_grade(self, velocity) +def stop_operation(self) +def send_command(self, full_command: str) +def set_baudrate(self, baudrate) +def close(self) +``` + +### pump_and_valve — 电磁阀子类型 + +已有设备:`solenoid_valve` / `solenoid_valve.mock` +驱动类:`unilabos.devices.pump_and_valve.solenoid_valve:SolenoidValve` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `valve_position` | `str` | 阀门位置 | + +**关键动作方法签名:** + +```python +def open(self) +def close(self) +def set_valve_position(self, position) # 参数名是 position +def is_open(self) +def is_closed(self) +def send_command(self, command: str) +``` + +### temperature — 温控设备 + +已有设备:`dalong_heaterstirrer`(加热搅拌器) +驱动类:`unilabos.devices.temperature.dalong:DalongHeaterStirrer` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `temp` | `float` | 当前温度 (°C) | +| `temp_target` | `float` | 目标温度 (°C) | +| `stir_speed` | `float` | 搅拌速度 (RPM) | +| `temp_warning` | `float` | 警告温度 (°C) | + +### motor — 电机设备 + +已有设备:`zdt_x42`(闭环步进电机) +驱动类:`unilabos.devices.motor.zdt_x42:ZDTX42Motor` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `status` | `str` | 状态 | +| `position` | `int` | 当前位置 | + +### sensor — 传感器 + +已有设备:`xkc_level_sensor`(液位传感器) +驱动类:`unilabos.devices.sensor.xkc_level_sensor:XKCLevelSensor` + +**status_types:** + +| 属性名 | 类型 | 说明 | +|---|---|---| +| `level` | `bool` | 液位状态 | +| `rssi` | `int` | 信号强度 | + +--- + +## 物模型代码模板 + +### temperature — 温控设备 + +```python +class MyTemperatureDevice: + """温控设备:加热器、冷却器、恒温槽等""" + + def __init__(self, device_id=None, config=None, **kwargs): + # ... 标准 init ... + self.data = { + "status": "Idle", + "temp": 25.0, + "temp_target": 25.0, + } + + async def set_temperature(self, temp: float, **kwargs) -> bool: + """设定目标温度 (°C)""" + temp = float(temp) + self.data["temp_target"] = temp + # >>> 在此填入实际指令 <<< + return True + + async def stop(self, **kwargs) -> bool: + self.data["status"] = "Idle" + # >>> 在此填入实际指令 <<< + return True + + @property + def temp(self) -> float: + return self.data.get("temp", 0.0) + + @property + def temp_target(self) -> float: + return self.data.get("temp_target", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### pump_and_valve — 注射泵 + +> **严禁重命名参数!** 下方模板中的参数名(`volume`、`position`、`max_velocity` 等)是接口契约。禁止加后缀(如 ~~`volume_ml`~~)、改名(如 ~~`speed_ml_s`~~)或用其他名字替代。单位信息写在 docstring 里,不写在参数名中。 + +```python +class MySyringePump: + """注射泵设备 — 含阀门控制""" + + def __init__(self, device_id=None, config=None, **kwargs): + # ... 标准 init ... + self.max_volume = float(config.get("max_volume", 25.0)) + self.total_steps = 6000 + self.data = { + "status": "Idle", # 必须用英文 "Idle" / "Busy" + "valve_position": "I", + "position": 0.0, # 当前体积位置 (mL) + # 第四步可能要求补充更多字段(如 max_velocity, mode 等) + } + + def initialize(self): + # >>> 发送初始化指令 <<< + return response + + def set_valve_position(self, position): + """设置阀门位置。参数名必须是 position""" + # >>> 发送阀门指令 <<< + return response + + def set_position(self, position: float, max_velocity: float = None): + """移动到绝对体积位置 (mL)。参数名 position / max_velocity 不可修改""" + pos_step = int(float(position) / self.max_volume * self.total_steps) + # >>> 发送绝对位置指令 <<< + return response + + def pull_plunger(self, volume: float): + """吸液 (mL)。参数名必须是 volume""" + pos_step = int(float(volume) / self.max_volume * self.total_steps) + # >>> 发送相对吸液指令 <<< + return response + + def push_plunger(self, volume: float): + """排液 (mL)。参数名必须是 volume""" + pos_step = int(float(volume) / self.max_volume * self.total_steps) + # >>> 发送相对排液指令 <<< + return response + + def stop_operation(self): + # >>> 发送终止指令 <<< + return response + + def close(self): + self.hardware_interface.close() + + @property + def status(self) -> str: + return self._status # "Idle" 或 "Busy" + + @property + def valve_position(self) -> str: + return self._valve_position + + @property + def position(self) -> float: + """当前体积位置 (mL)""" + return self._position +``` + +### pump_and_valve — 电磁阀 + +```python +class MySolenoidValve: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "valve_position": "closed"} + + async def open(self, **kwargs) -> bool: + return True + + async def close(self, **kwargs) -> bool: + return True + + async def set_valve_position(self, position: str, **kwargs) -> bool: + self.data["valve_position"] = str(position) + return True + + @property + def valve_position(self) -> str: + return self.data.get("valve_position", "closed") + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### pump_and_valve — 蠕动泵 + +```python +class MyPeristalticPump: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "speed": 0.0, "direction": "CW"} + + async def set_speed(self, speed: float, **kwargs) -> bool: + """设置流速 (mL/min)""" + self.data["speed"] = float(speed) + return True + + async def stop(self, **kwargs) -> bool: + self.data["speed"] = 0.0 + self.data["status"] = "Idle" + return True + + @property + def speed(self) -> float: + return self.data.get("speed", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### motor — 电机设备 + +```python +class MyMotor: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "position": 0, "speed": 0.0} + + async def enable(self, **kwargs) -> bool: + self.data["status"] = "Enabled" + return True + + async def move_position(self, position: int, speed: float = 100.0, **kwargs) -> bool: + position, speed = int(position), float(speed) + return True + + async def move_speed(self, speed: float, **kwargs) -> bool: + self.data["speed"] = float(speed) + return True + + async def stop(self, **kwargs) -> bool: + self.data["status"] = "Idle" + self.data["speed"] = 0.0 + return True + + @property + def position(self) -> int: + return self.data.get("position", 0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### heaterstirrer — 加热搅拌 + +```python +class MyHeaterStirrer: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = { + "status": "Idle", "temp": 25.0, "temp_target": 25.0, + "stir_speed": 0.0, "is_stirring": False, + } + + async def set_temperature(self, temp: float, **kwargs) -> bool: + self.data["temp_target"] = float(temp) + return True + + async def stir(self, stir_speed: float, stir_time: float = 0, settling_time: float = 0, **kwargs) -> bool: + self.data["stir_speed"] = float(stir_speed) + self.data["is_stirring"] = True + if stir_time > 0: + start = time_module.time() + while time_module.time() - start < stir_time: + self.data["remaining_time"] = max(0, stir_time - (time_module.time() - start)) + await self._ros_node.sleep(1.0) + self.data["is_stirring"] = False + return True + + async def stop(self, **kwargs) -> bool: + self.data.update({"status": "Idle", "stir_speed": 0.0, "is_stirring": False}) + return True + + @property + def temp(self) -> float: + return self.data.get("temp", 25.0) + + @property + def stir_speed(self) -> float: + return self.data.get("stir_speed", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### balance — 天平 + +```python +class MyBalance: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "weight": 0.0, "unit": "g", "stable": True} + + def read_weight(self, **kwargs) -> Dict[str, Any]: + return {"success": True, "weight_g": self.data["weight"], "stable": self.data["stable"]} + + def tare(self, **kwargs) -> Dict[str, Any]: + self.data["weight"] = 0.0 + return {"success": True, "message": "去皮完成"} + + @property + def weight(self) -> float: + return self.data.get("weight", 0.0) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +### sensor — 传感器 + +```python +class MySensor: + def __init__(self, device_id=None, config=None, **kwargs): + self.data = {"status": "Idle", "value": 0.0, "level": False} + + def read_value(self, **kwargs) -> Dict[str, Any]: + return {"success": True, "value": self.data["value"]} + + async def wait_for_level(self, target_level: bool = True, timeout: float = 60.0, **kwargs) -> bool: + start = time_module.time() + while time_module.time() - start < float(timeout): + if self.data["level"] == bool(target_level): + return True + await self._ros_node.sleep(0.5) + return False + + @property + def value(self) -> float: + return self.data.get("value", 0.0) + + @property + def level(self) -> bool: + return self.data.get("level", False) + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +--- + +## 指令协议模式 + +通信协议解决"用什么方式通信",指令协议解决"发什么内容"。 + +### 模式 1:文本指令 + +```python +def _send_command(self, cmd: str) -> str: + self.ser.write(f"{cmd}\r\n".encode()) + return self.ser.readline().decode().strip() +``` + +### 模式 2:自定义二进制帧 + +```python +def _build_frame(self, func_code: int, data: bytes) -> bytes: + frame = bytearray([0xFE, func_code]) + bytearray(data) + while len(frame) < 5: + frame.append(0x00) + checksum = sum(frame[1:]) % 256 + frame.append(checksum) + return bytes(frame) + +def _send_frame(self, func_code: int, data: bytes) -> bytes: + frame = self._build_frame(func_code, data) + self.ser.write(frame) + return self.ser.read(6) +``` + +### 模式 3:Modbus 寄存器读写 + +```python +REGISTER_MAP = { + "temp_target": {"addr": 0x000B, "scale": 10}, + "temp_current": {"addr": 0x0001, "scale": 10}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + temp = float(temp) + reg = REGISTER_MAP["temp_target"] + value = int(temp * reg["scale"]) & 0xFFFF + self.client.write_register(reg["addr"], value, slave=self.slave_id) + self.data["temp_target"] = temp + return True +``` + +### 模式 4:JSON/REST API + +```python +API_MAP = { + "set_temperature": {"method": "POST", "endpoint": "/api/temperature", "body_key": "target"}, + "get_status": {"method": "GET", "endpoint": "/api/status"}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + api = API_MAP["set_temperature"] + resp = self._post(api["endpoint"], {api["body_key"]: float(temp)}) + return resp.get("success", False) +``` + +### 模式 5:SDK 封装 + +```python +from my_device_sdk import DeviceController + +class MyDevice: + def __init__(self, device_id=None, config=None, **kwargs): + self.controller = DeviceController(port=config.get('port', 'COM1')) + self.data = {"status": "Idle"} + + def set_temperature(self, temp: float, **kwargs) -> bool: + self.controller.set_target_temp(float(temp)) + return True +``` + +--- + +## 通信协议代码片段 + +### Serial(RS232 / RS485) + +```python +import serial + +self.ser = serial.Serial( + port=self.config.get('port', 'COM1'), + baudrate=self.config.get('baudrate', 9600), + timeout=self.config.get('timeout', 1), +) + +# cleanup: +if hasattr(self, 'ser') and self.ser.is_open: + self.ser.close() +``` + +**串口响应解析健壮性(重要):** RS-485 半双工总线上,设备响应前经常有前导垃圾字节(TX 回声、总线噪声等)。**禁止用硬编码索引直接解析原始响应**,必须先定位帧起始标记: + +```python +# ✗ 错误 — 假设响应从 index 0 开始,前导垃圾字节会导致所有解析偏移 +status_byte = ord(response[2]) +data = response[3:etx_pos] + +# ✓ 正确 — 先找到帧起始标记,再用相对偏移解析 +def _normalize_response(self, raw: str, start_marker: str = "/") -> str: + """去除帧起始标记之前的垃圾字节""" + pos = raw.find(start_marker) + return raw[pos:] if pos >= 0 else raw + +# 在 _send_command 返回前调用: +resp_str = self._normalize_response(resp_str) +``` + +同理,二进制帧协议也必须先查找帧头字节(如 `0xFE`),不能假设 `response[0]` 就是帧头。 + +### Modbus RTU + +```python +from pymodbus.client import ModbusSerialClient + +self.client = ModbusSerialClient( + port=self.config.get('port', 'COM1'), + baudrate=self.config.get('baudrate', 9600), + timeout=self.config.get('timeout', 1), +) +self.client.connect() +self.slave_id = self.config.get('slave_id', 1) +``` + +### Modbus TCP + +```python +from pymodbus.client import ModbusTcpClient + +self.client = ModbusTcpClient( + host=self.config.get('host', '192.168.1.100'), + port=self.config.get('port', 502), +) +self.client.connect() +self.slave_id = self.config.get('slave_id', 1) +``` + +### TCP Socket + +```python +import socket + +self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +self.sock.settimeout(self.config.get('timeout', 5)) +self.sock.connect((self.config['host'], self.config['port'])) +``` + +### HTTP API + +```python +import requests + +self.base_url = self.config.get('url', 'http://localhost:8080') +self.session = requests.Session() +``` + +### OPC UA + +```python +from opcua import Client + +self.opc_client = Client(self.config.get('url', 'opc.tcp://localhost:4840')) +self.opc_client.connect() +``` + +--- + +## 常见错误(必读) + +以下是历史上导致设备无法接入的真实案例,**生成代码后必须逐条对照检查**: + +### 错误 1:重命名模板参数名 + +```python +# ✗ 错误 +async def pull_plunger(self, volume_ml: float, speed_ml_s: float = None, **kwargs): +# ✓ 正确 +async def pull_plunger(self, volume: float, **kwargs): + +# ✗ 错误 +async def set_position(self, position_ml: float, speed_ml_s: float = None, **kwargs): +# ✓ 正确 +async def set_position(self, position: float, max_velocity: float = None, **kwargs): + +# ✗ 错误 +async def set_valve_position(self, valve_position: int, **kwargs): +# ✓ 正确 +async def set_valve_position(self, position, **kwargs): +``` + +### 错误 2:status 字符串使用中文 + +```python +# ✗ 错误 +self.data["status"] = "就绪" +# ✓ 正确 +self.data["status"] = "Idle" +``` + +### 错误 3:self.data 初始化为空字典 + +```python +# ✗ 错误 +self.data = {} +# ✓ 正确 +self.data = {"status": "Idle", "valve_position": "I", "position": 0.0, "max_velocity": 0.0} +``` + +### 错误 4:跳过第四步,缺失已有设备的属性 + +```python +# ✓ 即使硬件不直接支持,也要提供属性(返回默认值) +@property +def max_velocity(self) -> float: + return self.data.get("max_velocity", 0.0) +``` + +### 错误 5:在 async 方法中使用 time.sleep() + +```python +# ✗ 错误 +time.sleep(0.5) +# ✓ 正确 +await self._ros_node.sleep(0.5) +``` + +### 错误 6:用硬编码索引解析串口响应 + +```python +# ✗ 错误 — RS-485 响应前有回声/噪声字节时,所有索引偏移,解析全部出错 +# 而且 _parse_error / _is_busy 可能歪打正着返回"正确"结果, +# 导致轮询失效(永远认为设备空闲)、错误被吞、状态查询异常 +status_byte = ord(response[2]) +data = response[3:etx_pos] + +# ✓ 正确 — 先定位帧起始标记(如 /、0xFE 等),再用相对偏移 +start = response.find("/") +if start >= 0: + response = response[start:] +status_byte = ord(response[2]) +data = response[3:etx_pos] +``` + +**规则:** 串口协议解析必须先定位帧起始标记,禁止假设 `response[0]` 就是帧头。 + +--- + +## 返回值设计 + +```python +return { + "success": True, + "message": "操作完成", + "temperature_celsius": 25.5, +} +``` + +--- + +## 图文件:工作站配置 + +工作站需要 `deck` 和 `children`: + +```json +{ + "nodes": [ + { + "id": "my_station", + "type": "device", + "class": "my_workstation", + "children": ["my_deck"], + "config": {}, + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module:MyDeck" + } + } + }, + { + "id": "my_deck", + "type": "deck", + "class": "MyDeckClass", + "parent": "my_station", + "children": [], + "config": {"type": "MyDeckClass", "setup": true} + } + ] +} +``` diff --git a/docs/ai_guides/agent_prompt_template.md b/docs/ai_guides/agent_prompt_template.md new file mode 100644 index 00000000..99dee338 --- /dev/null +++ b/docs/ai_guides/agent_prompt_template.md @@ -0,0 +1,344 @@ +# Uni-Lab-OS 设备接入 Agent — 提示词模板 + +> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。 +> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。 + +--- + +## 系统提示词模板 + +以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。 + +--- + +### 开始复制 ↓ + +``` +你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。 + +你能做的事: +- 根据用户描述,生成完整的设备驱动代码(Python)、注册表(YAML)和实验图文件(JSON) +- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式 +- 诊断已有驱动代码的接口对齐问题 + +你不能做的事: +- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取) +- 替代真实硬件联调测试 + +## 知识来源 + +{{KNOWLEDGE_LOADING}} + +## 工作流程 + +当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。 + +### 阶段 1:设备画像(交互) + +向用户收集以下三个信息,可以一次性提问: + +1. **设备类别** — 属于以下哪一种? + - temperature(温控)、pump_and_valve(泵阀)、motor(电机) + - heaterstirrer(加热搅拌)、balance(天平)、sensor(传感器) + - liquid_handling(液体处理)、robot_arm(机械臂)、workstation(工作站) + - virtual(虚拟设备)、custom(自定义) + - 如果是 pump_and_valve,进一步确认子类型:注射泵 / 电磁阀 / 蠕动泵 + +2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b) + +3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟) + +⏸️ **暂停:等待用户回答后继续** + +### 阶段 2:指令协议收集(交互) + +根据上一步确定的通信协议,引导用户提供指令信息: + +- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑 +- 如果用户有 **协议文档**:请用户提供文档(PDF/图片/文本),你从中解析指令格式 +- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令 +- 如果是 **标准协议**(Modbus 寄存器表、SCPI):请用户提供寄存器/指令映射 +- 如果是 **虚拟设备**:跳过此阶段 + +⏸️ **暂停:确认已获取足够的指令协议信息** + +### 阶段 3:确认摘要 + +在开始生成代码前,向用户展示你的理解摘要: + +``` +设备接入摘要: +- 设备名称: +- 设备类别:) +- 通信协议: +- 指令来源: +- 将要实现的属性: +- 将要实现的动作: +- 同类已有设备:(将对齐其接口) +``` + +⏸️ **暂停:用户确认"没问题"后再生成代码** + +### 阶段 4:自动生成(无需暂停) + +按以下顺序自动执行: + +1. **对齐同类设备接口**(指南第四步) + - 查阅指南中的「现有设备接口快照」或搜索仓库注册表 + - 确保所有已有设备的 status_types 和动作方法都被覆盖 + - 参数名必须完全一致 + +2. **生成驱动代码** — `unilabos/devices//.py` + +3. **生成注册表** — `unilabos/registry/devices/.yaml`(最小配置) + +4. **生成图文件** — `unilabos/test/experiments/graph_example_.json` + +### 阶段 5:验证输出 + +生成完成后,逐项检查对齐验证清单并展示结果: + +``` +对齐验证清单: +- [x] 所有动作方法的参数名与已有设备完全一致 +- [x] status 属性返回的字符串值与已有设备一致 +- [x] 已有设备的所有 status_types 字段都有对应 @property +- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法 +- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值 +- [x] 串口/二进制协议的响应解析先定位帧起始标记 +``` + +如果有未通过的项,主动修复后再展示。 + +## 硬约束(违反任何一条都会导致设备接入失败) + +1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity)是接口契约,框架通过参数名分派调用。绝不能加后缀(如 volume_ml)、改名(如 speed_ml_s)。单位写在 docstring 中。 + +2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。 + +3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。 + +4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。 + +5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE),禁止用硬编码索引直接解析。 + +6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。 + +7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位(mL、°C、RPM),驱动内部负责转换到硬件原始值(步数、Hz、寄存器值)。 + +## 代码骨架参考 + +所有设备驱动遵循以下结构: + +```python +import logging +import time as time_module +from typing import Dict, Any + +try: + from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +except ImportError: + BaseROS2DeviceNode = None + +class MyDevice: + _ros_node: "BaseROS2DeviceNode" + + def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs): + if device_id is None and 'id' in kwargs: + device_id = kwargs.pop('id') + if config is None and 'config' in kwargs: + config = kwargs.pop('config') + self.device_id = device_id or "unknown_device" + self.config = config or {} + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + self.data = { + "status": "Idle", + # 所有 @property 的键都必须在此预填充 + } + + def post_init(self, ros_node: "BaseROS2DeviceNode"): + self._ros_node = ros_node + + async def initialize(self) -> bool: + self.data["status"] = "Idle" + return True + + async def cleanup(self) -> bool: + self.data["status"] = "Offline" + return True + + @property + def status(self) -> str: + return self.data.get("status", "Idle") +``` + +## 注册表最小配置 + +```yaml +my_device: + class: + module: unilabos.devices..:MyDevice + type: python +``` + +启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。 + +## 图文件模板 + +```json +{ + "nodes": [ + { + "id": "my_device_1", + "name": "设备名称", + "children": [], + "parent": null, + "type": "device", + "class": "my_device", + "position": {"x": 0, "y": 0, "z": 0}, + "config": {}, + "data": {} + } + ] +} +``` + +## 现有设备接口快照(对齐用) + +对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本: +https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/ + +### pump_and_valve — 注射泵 + +已有设备:syringe_pump_with_valve.runze.SY03B-T06 + +属性:status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String) + +方法签名(参数名不可改): +- initialize() +- set_valve_position(position) +- set_position(position: float, max_velocity: float = None) +- pull_plunger(volume: float) +- push_plunger(volume: float) +- set_max_velocity(velocity: float) +- set_velocity_grade(velocity) +- stop_operation() + +### pump_and_valve — 电磁阀 + +属性:status(str), valve_position(str) +方法:open(), close(), set_valve_position(position), is_open(), is_closed() + +### temperature + +属性:status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C) + +### motor + +属性:status(str), position(int) + +### sensor + +属性:level(bool), rssi(int) +``` + +### 结束复制 ↑ + +--- + +## `{{KNOWLEDGE_LOADING}}` 变量替换 + +根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容: + +### 方案 A:有知识库(Custom GPT / Claude Project) + +``` +你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。 +执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。 +本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。 +``` + +### 方案 B:有联网能力 + +``` +执行工作流前,从以下 URL 获取完整的设备接入指南: +https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md + +该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。 +如果无法访问 URL,使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。 +``` + +### 方案 C:无知识库、无联网 + +``` +完整的设备接入指南需要用户在对话中提供。 +如果用户未主动提供,请在阶段 1 开始前询问: +"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。" + +本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。 +但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。 +``` + +--- + +## 各平台配置指南 + +### OpenAI Custom GPT + +1. 进入 https://chat.openai.com/gpts/editor +2. **Name**:Uni-Lab-OS 设备接入助手 +3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。 +4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A +5. **Knowledge**:上传 `docs/ai_guides/add_device.md` +6. **Capabilities**:开启 Code Interpreter(用于代码验证) +7. **Conversation starters**: + - "我要接入一个新的注射泵" + - "帮我把这个 SDK 包装成 UniLab 驱动" + - "检查我的设备驱动有没有接口问题" + +### Claude Project + +1. 创建新 Project +2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A +3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md` + +### API Agent(LangChain / AutoGen / 自建框架) + +```python +system_prompt = """ +<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B> +""" + +# 如果框架支持工具调用,可注册以下工具: +tools = [ + { + "name": "fetch_device_guide", + "description": "获取最新的 Uni-Lab-OS 设备接入指南", + "url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md" + }, + { + "name": "fetch_registry", + "description": "获取最新的设备注册表", + "url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml" + }, +] +``` + +### Cursor Agent Mode + +无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力(Grep 搜索注册表、AskQuestion 收集信息等)。 + +### 纯网页对话(ChatGPT / Claude 无 Project) + +1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C) +2. 第二条消息上传或粘贴 `add_device.md` +3. 第三条消息开始描述设备 + +--- + +## 维护说明 + +- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分 +- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分 +- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分 +- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护