diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md new file mode 100644 index 00000000..61b6252e --- /dev/null +++ b/.cursor/skills/add-device/SKILL.md @@ -0,0 +1,160 @@ +--- +name: add-device +description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型. +--- + +# 添加新设备到 Uni-Lab-OS + +**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。 + +该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。 + +--- + +## 装饰器参考 + +### @device — 设备类装饰器 + +```python +from unilabos.registry.decorators import device + +# 单设备 +@device( + id="my_device.vendor", # 注册表唯一标识(必填) + category=["temperature"], # 分类标签列表(必填) + description="设备描述", # 设备描述 + display_name="显示名称", # UI 显示名称(默认用 id) + icon="DeviceIcon.webp", # 图标文件名 + version="1.0.0", # 版本号 + device_type="python", # "python" 或 "ros2" + handles=[...], # 端口列表(InputHandle / OutputHandle) + model={...}, # 3D 模型配置 + hardware_interface=HardwareInterface(...), # 硬件通信接口 +) + +# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置) +@device( + ids=["pump.vendor.model_A", "pump.vendor.model_B"], + id_meta={ + "pump.vendor.model_A": {"handles": [...], "description": "型号 A"}, + "pump.vendor.model_B": {"handles": [...], "description": "型号 B"}, + }, + category=["pump_and_valve"], +) +``` + +### @action — 动作方法装饰器 + +```python +from unilabos.registry.decorators import action + +@action # 无参:注册为 UniLabJsonCommand 动作 +@action() # 同上 +@action(description="执行操作") # 带描述 +@action( + action_type=HeatChill, # 指定 ROS Action 消息类型 + goal={"temperature": "temp"}, # Goal 字段映射 + feedback={}, # Feedback 字段映射 + result={}, # Result 字段映射 + handles=[...], # 动作级别端口 + goal_default={"temp": 25.0}, # Goal 默认值 + placeholder_keys={...}, # 参数占位符 + always_free=True, # 不受排队限制 + auto_prefix=True, # 强制使用 auto- 前缀 + parent=True, # 从父类 MRO 获取参数签名 +) +``` + +**自动识别规则:** +- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名) +- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作 +- `_` 开头的方法 → 不扫描 +- `@not_action` 标记的方法 → 排除 + +### @topic_config — 状态属性配置 + +```python +from unilabos.registry.decorators import topic_config + +@property +@topic_config( + period=5.0, # 发布周期(秒),默认 5.0 + print_publish=False, # 是否打印发布日志 + qos=10, # QoS 深度,默认 10 + name="custom_name", # 自定义发布名称(默认用属性名) +) +def temperature(self) -> float: + return self.data.get("temperature", 0.0) +``` + +### 辅助装饰器 + +```python +from unilabos.registry.decorators import not_action, always_free + +@not_action # 标记为非动作(post_init、辅助方法等) +@always_free # 标记为不受排队限制(查询类操作) +``` + +--- + +## 设备模板 + +```python +import logging +from typing import Any, Dict, Optional + +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +from unilabos.registry.decorators import device, action, topic_config, not_action + +@device(id="my_device", category=["my_category"], description="设备描述") +class MyDevice: + _ros_node: BaseROS2DeviceNode + + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + self.device_id = device_id or "my_device" + self.config = config or {} + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + self.data: Dict[str, Any] = {"status": "Idle"} + + @not_action + def post_init(self, ros_node: BaseROS2DeviceNode) -> None: + self._ros_node = ros_node + + @action + async def initialize(self) -> bool: + self.data["status"] = "Ready" + return True + + @action + async def cleanup(self) -> bool: + self.data["status"] = "Offline" + return True + + @action(description="执行操作") + def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]: + """带 @action 装饰器 → 注册为 'my_action' 动作""" + return {"success": True} + + def get_info(self) -> Dict[str, Any]: + """无 @action → 自动注册为 'auto-get_info' 动作""" + return {"device_id": self.device_id} + + @property + @topic_config() + def status(self) -> str: + return self.data.get("status", "Idle") + + @property + @topic_config(period=2.0) + def temperature(self) -> float: + return self.data.get("temperature", 0.0) +``` + +### 要点 + +- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部 +- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)` +- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode` +- 运行时状态存储在 `self.data` 字典中 +- 设备文件放在 `unilabos/devices//` 目录下 diff --git a/.cursor/skills/add-resource/SKILL.md b/.cursor/skills/add-resource/SKILL.md new file mode 100644 index 00000000..1b67a872 --- /dev/null +++ b/.cursor/skills/add-resource/SKILL.md @@ -0,0 +1,351 @@ +--- +name: add-resource +description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. 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 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。 + +--- + +## 资源类型 + +| 类型 | 基类 | 用途 | 示例 | +|------|------|------|------| +| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 | +| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 | +| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 | +| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck | + +**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle` + +WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。 + +--- + +## @resource 装饰器 + +```python +from unilabos.registry.decorators import resource + +@resource( + id="my_resource_id", # 注册表唯一标识(必填) + category=["bottles"], # 分类标签列表(必填) + description="资源描述", + icon="", # 图标 + version="1.0.0", + handles=[...], # 端口列表(InputHandle / OutputHandle) + model={...}, # 3D 模型配置 + class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos" +) +``` + +--- + +## 创建规范 + +### 命名规则 + +1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一 +2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial +3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致 + +### 子物料命名示例 + +```python +# Carrier 内部的 sites 用 name 前缀 +for k, v in sites.items(): + v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ... + +# Carrier 中放置 Bottle 时用 name 前缀 +carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1" +carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1" + +# create_homogeneous_resources 使用 name_prefix +sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[...], + name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ... +) + +# Deck setup 中用仓库名称作为 name 传入 +self.warehouses = { + "堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左" + "试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈" +} +``` + +### 其他规范 + +- **max_volume 单位为 μL**:500mL = 500000 +- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz` +- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局 +- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()` +- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources//` 下 +- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错 +- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型 + +--- + +## 资源模板 + +### Bottle + +```python +from unilabos.registry.decorators import resource +from unilabos.resources.itemized_carrier import Bottle + + +@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶") +def My_Reagent_Bottle( + name: str, + diameter: float = 70.0, + height: float = 120.0, + max_volume: float = 500000.0, + barcode: str = None, +) -> Bottle: + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="My_Reagent_Bottle", + ) +``` + +**Bottle 参数:** +- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入) +- `diameter`: 瓶体直径 (mm) +- `height`: 瓶体高度 (mm) +- `max_volume`: 最大容积(**μL**,500mL = 500000) +- `barcode`: 条形码(可选) + +### BottleCarrier + +```python +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.carrier import create_ordered_items_2d +from unilabos.resources.itemized_carrier import BottleCarrier +from unilabos.registry.decorators import resource + + +@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架") +def My_6SlotCarrier(name: str) -> BottleCarrier: + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, num_items_y=2, + dx=10.0, dy=10.0, dz=5.0, + item_dx=42.0, item_dy=35.0, + size_x=20.0, size_y=20.0, size_z=50.0, + ) + # 子 site 用 name 作为前缀 + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + 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 时用 name 作为前缀 + ordering = ["A1", "B1", "A2", "B2", "A3", "B3"] + for i in range(6): + carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}") + return carrier +``` + +### WareHouse / Deck 放置位 + +WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。 + +#### WareHouse(使用 warehouse_factory) + +```python +from unilabos.resources.warehouse import warehouse_factory +from unilabos.registry.decorators import resource + + +@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库") +def my_warehouse_4x4(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, # 第一个 slot 的起始偏移 + item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距 + resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸 + model="my_warehouse_4x4", + col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05) + layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向 + ) +``` + +`warehouse_factory` 参数说明: +- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm) +- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量 +- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸 +- `layout`:影响 slot 标签和坐标映射 + - `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列) + - `"col-major"`:A01,B01,...,A02,B02,...(列优先) + - `"vertical-col-major"`:竖向排列,y 坐标反向 + +#### Deck 组装 WareHouse + +Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标: + +```python +from pylabrobot.resources import Deck, Coordinate +from unilabos.registry.decorators import resource + + +@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck") +class MyStation_Deck(Deck): + def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0, + category="deck", setup=False, **kwargs) -> 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 = { + "堆栈1左": my_warehouse_4x4("堆栈1左"), + "堆栈1右": my_warehouse_4x4("堆栈1右"), + } + self.warehouse_locations = { + "堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算 + "堆栈1右": 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]) +``` + +#### Site 模式(前端定向放置) + +适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局: + +```python +import collections +from typing import Any, Dict, List, Optional +from pylabrobot.resources import Deck, Resource, Coordinate +from unilabos.registry.decorators import resource + + +@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck") +class MyLabDeck(Deck): + # 根据设备台面实测批量计算各 slot 坐标偏移 + _DEFAULT_SITE_POSITIONS = [ + (0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4 + (0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8 + ] + _DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0} + _DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"] + + def __init__(self, name: str, size_x: float, size_y: float, size_z: float, + sites: Optional[List[Dict[str, Any]]] = None, **kwargs): + super().__init__(size_x, size_y, size_z, name) + if sites is not None: + self.sites = [dict(s) for s in sites] + else: + self.sites = [] + for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS): + self.sites.append({ + "label": f"T{i + 1}", # 前端显示的槽位标签 + "visible": True, # 是否在前端可见 + "position": {"x": x, "y": y, "z": z}, # 槽位物理坐标 + "size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸 + "content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型 + }) + self._ordering = collections.OrderedDict( + (site["label"], None) for site in self.sites + ) + + def assign_child_resource(self, resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True, + spot: Optional[int] = None): + idx = spot + if spot is None: + for i, site in enumerate(self.sites): + if site.get("label") == resource.name: + idx = i + break + if idx is None: + for i in range(len(self.sites)): + if self._get_site_resource(i) is None: + idx = i + break + if idx is None: + raise ValueError(f"No available site for '{resource.name}'") + loc = Coordinate(**self.sites[idx]["position"]) + super().assign_child_resource(resource, location=loc, reassign=reassign) + + def serialize(self) -> dict: + data = super().serialize() + sites_out = [] + for i, site in enumerate(self.sites): + occupied = self._get_site_resource(i) + sites_out.append({ + "label": site["label"], + "visible": site.get("visible", True), + "occupied_by": occupied.name if occupied else None, + "position": site["position"], + "size": site["size"], + "content_type": site["content_type"], + }) + data["sites"] = sites_out + return data +``` + +**Site 字段说明:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name | +| `visible` | bool | 是否在前端可见 | +| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 | +| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) | +| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` | + +**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。 + +--- + +## 文件位置 + +``` +unilabos/resources/ +├── / # 按项目分组 +│ ├── bottles.py # Bottle 工厂函数 +│ ├── bottle_carriers.py # Carrier 工厂函数 +│ ├── warehouses.py # WareHouse 工厂函数 +│ └── decks.py # Deck 类定义 +``` + +--- + +## 验证 + +```bash +# 资源可导入 +python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))" + +# 启动测试(AST 自动扫描) +unilab -g .json +``` + +仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。 + +--- + +## 关键路径 + +| 内容 | 路径 | +|------|------| +| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` | +| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` | +| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` | +| 装饰器定义 | `unilabos/registry/decorators.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..534e5ba6 --- /dev/null +++ b/.cursor/skills/add-workstation/SKILL.md @@ -0,0 +1,626 @@ +--- +name: add-workstation +description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation. +--- + +# Uni-Lab-OS 工作站接入指南 + +工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。 + +--- + +## 工作站类型 + +| 类型 | 基类 | 适用场景 | +| ------------------- | ----------------- | ---------------------------------- | +| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) | +| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 | +| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 | + +--- + +## @device 装饰器(工作站) + +工作站也使用 `@device` 装饰器注册,参数与普通设备一致: + +```python +@device( + id="my_workstation", # 注册表唯一标识(必填) + category=["workstation"], # 分类标签 + description="我的工作站", +) +``` + +如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。 + +--- + +## 工作站驱动模板 + +### 模板 A:基于外部系统的工作站 + +```python +import logging +from typing import Dict, Any, Optional +from pylabrobot.resources import Deck + +from unilabos.registry.decorators import device, topic_config, not_action +from unilabos.devices.workstation.workstation_base import WorkstationBase + +try: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +except ImportError: + ROS2WorkstationNode = None + + +@device(id="my_workstation", category=["workstation"], description="我的工作站") +class MyWorkstation(WorkstationBase): + _ros_node: "ROS2WorkstationNode" + + def __init__(self, config=None, deck=None, protocol_type=None, **kwargs): + super().__init__(deck=deck, **kwargs) + self.config = config or {} + self.logger = logging.getLogger("MyWorkstation") + self.api_host = self.config.get("api_host", "") + self._status = "Idle" + + @not_action + def post_init(self, ros_node: "ROS2WorkstationNode"): + super().post_init(ros_node) + self._ros_node = ros_node + + 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 + @topic_config() + def workflow_sequence(self) -> str: + return "[]" + + @property + @topic_config() + def material_info(self) -> str: + return "{}" +``` + +### 模板 B:Protocol 工作站 + +直接使用 `ProtocolNode`,通常不需要自定义驱动类: + +```python +from unilabos.devices.workstation.workstation_base import ProtocolNode +``` + +在图文件中配置 `protocol_type` 即可。 + +--- + +## 子设备访问(sub_devices) + +工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法: + +```python +# 在工站驱动类的方法中访问子设备 +sub = self._ros_node.sub_devices["pump_1"] + +# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例) +sub.driver_instance.some_method(arg1, arg2) + +# .ros_node_instance — 子设备的 ROS2 节点实例 +sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action +``` + +**常见用法**: + +```python +class MyWorkstation(WorkstationBase): + def my_protocol(self, **kwargs): + # 获取子设备驱动实例 + pump = self._ros_node.sub_devices["pump_1"].driver_instance + heater = self._ros_node.sub_devices["heater_1"].driver_instance + + # 直接调用子设备方法 + pump.aspirate(volume=100) + heater.set_temperature(80) +``` + +> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。 + +--- + +## 硬件通信接口(hardware_interface) + +硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。 + +### 工作原理 + +`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`): + +**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。 + +**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置: + +``` +hardware_interface = d.ros_node_instance._hardware_interface +# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"} +``` + +1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])` + - 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换 +2. 从通信设备获取真正的 `read`/`write` 方法 +3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上 + +因此: + +- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`) +- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备) +- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化 + +### HardwareInterface 参数说明 + +```python +from unilabos.registry.decorators import HardwareInterface + +HardwareInterface( + name="hardware_interface", # __init__ 中接收通信实例的属性名 + read="send_command", # 通信设备上暴露的读方法名 + write="send_command", # 通信设备上暴露的写方法名 + extra_info=["list_ports"], # 可选:额外暴露的方法 +) +``` + +**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。 + +### 示例 1:泵(name="hardware_interface") + +```python +from unilabos.registry.decorators import device, HardwareInterface + +@device( + id="my_pump", + category=["pump_and_valve"], + hardware_interface=HardwareInterface( + name="hardware_interface", + read="send_command", + write="send_command", + ), +) +class MyPump: + def __init__(self, port=None, address="1", **kwargs): + # name="hardware_interface" → 系统替换 self.hardware_interface + self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例 + self.address = address + + def send_command(self, command: str): + full_command = f"/{self.address}{command}\r\n" + self.hardware_interface.write(bytearray(full_command, "ascii")) + return self.hardware_interface.read_until(b"\n") +``` + +### 示例 2:电磁阀(name="io_device_port",自定义属性名) + +```python +@device( + id="solenoid_valve", + category=["pump_and_valve"], + hardware_interface=HardwareInterface( + name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port + read="read_io_coil", + write="write_io_coil", + ), +) +class SolenoidValve: + def __init__(self, io_device_port: str = None, **kwargs): + # name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1" + self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例 +``` + +### Serial 通信设备(class="serial") + +`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`: + +```python +from serial import Serial, SerialException +from threading import Lock + +class ROS2SerialNode(BaseROS2DeviceNode): + def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs): + self.port = port + self.baudrate = baudrate + self._hardware_interface = { + "name": "hardware_interface", + "write": "send_command", + "read": "read_data", + } + self._query_lock = Lock() + + self.hardware_interface = Serial(baudrate=baudrate, port=port) + + BaseROS2DeviceNode.__init__( + self, driver_instance=self, registry_name=registry_name, + device_id=device_id, status_types={}, action_value_mappings={}, + hardware_interface=self._hardware_interface, print_publish=False, + ) + self.create_service(SerialCommand, "serialwrite", self.handle_serial_request) + + def send_command(self, command: str): + with self._query_lock: + self.hardware_interface.write(bytearray(f"{command}\n", "ascii")) + return self.hardware_interface.read_until(b"\n").decode() + + def read_data(self): + with self._query_lock: + return self.hardware_interface.read_until(b"\n").decode() +``` + +在图文件中使用 `"class": "serial"` 即可创建串口代理: + +```json +{ + "id": "serial_pump", + "class": "serial", + "parent": "my_station", + "config": { "port": "COM7", "baudrate": 9600 } +} +``` + +### 图文件配置 + +**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化: + +```json +{ + "nodes": [ + { + "id": "my_station", + "class": "workstation", + "children": ["serial_pump", "pump_1", "pump_2"], + "config": { "protocol_type": ["PumpTransferProtocol"] } + }, + { + "id": "serial_pump", + "class": "serial", + "parent": "my_station", + "config": { "port": "COM7", "baudrate": 9600 } + }, + { + "id": "pump_1", + "class": "syringe_pump_with_valve.runze.SY03B-T08", + "parent": "my_station", + "config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 } + }, + { + "id": "pump_2", + "class": "syringe_pump_with_valve.runze.SY03B-T08", + "parent": "my_station", + "config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 } + } + ], + "links": [ + { + "source": "pump_1", + "target": "serial_pump", + "type": "communication", + "port": { "pump_1": "port", "serial_pump": "port" } + }, + { + "source": "pump_2", + "target": "serial_pump", + "type": "communication", + "port": { "pump_2": "port", "serial_pump": "port" } + } + ] +} +``` + +### 通信协议速查 + +| 协议 | config 参数 | 依赖包 | 通信设备 class | +| -------------------- | ------------------------------ | ---------- | -------------------------- | +| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` | +| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` | +| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` | +| TCP Socket | `host`, `port` | stdlib | 自定义 | +| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` | + +参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json` + +--- + +## Deck 与物料生命周期 + +### 1. Deck 入参与两种初始化模式 + +系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。 + +有两种初始化模式: + +#### init 初始化(推荐) + +`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。 + +```json +"config": { + "deck": { + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck", + "_resource_child_name": "PRCXI_Deck" + } +} +``` + +#### deserialize 初始化 + +`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等): + +```json +"config": { + "deck": { + "data": { + "_resource_child_name": "YB_Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck" + } + } +} +``` + +没有特殊需求时推荐 init 初始化。 + +#### config.deck 字段说明 + +| 字段 | 说明 | +|------|------| +| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) | +| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 | + +#### 设备 __init__ 接收 + +```python +def __init__(self, config=None, deck=None, protocol_type=None, **kwargs): + super().__init__(deck=deck, **kwargs) + # deck 已经是反序列化后的 Deck 实例 + # → PRCXI9300Deck / BIOYOND_YB_Deck 等 +``` + +#### Deck 节点(图文件中) + +Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id: + +```json +{ + "id": "PRCXI_Deck", + "parent": "PRCXI", + "type": "deck", + "class": "", + "children": [], + "config": { + "type": "PRCXI9300Deck", + "size_x": 542, "size_y": 374, "size_z": 0, + "category": "deck", + "sites": [...] + }, + "data": {} +} +``` + +- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段) +- `children` 初始为空时,由同步器或手动初始化填充 +- `config.type` 填 Deck 类名 + +### 2. Deck 为空时自行初始化 + +如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容: + +```python +@not_action +def post_init(self, ros_node): + super().post_init(ros_node) + if self.deck and not self.deck.children: + self._initialize_default_deck() + +def _initialize_default_deck(self): + from my_labware import My_TipRack, My_Plate + self.deck.assign_child_resource(My_TipRack("T1"), spot=0) + self.deck.assign_child_resource(My_Plate("T2"), spot=1) +``` + +### 3. 物料双向同步 + +当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步: + +```python +from unilabos.devices.workstation.workstation_base import ResourceSynchronizer + +class MyResourceSynchronizer(ResourceSynchronizer): + def sync_from_external(self) -> bool: + """从外部系统同步到 self.workstation.deck""" + external_data = self._query_external_materials() + # 以外部工站为准:根据外部数据反向创建 PLR 资源实例 + for item in external_data: + cls = self._resolve_resource_class(item["type"]) + resource = cls(name=item["name"], **item["params"]) + self.workstation.deck.assign_child_resource(resource, spot=item["slot"]) + return True + + def sync_to_external(self, resource) -> bool: + """将 UniLab 侧物料变更同步到外部系统""" + # 以 UniLab 为准:将 PLR 资源转为外部格式并推送 + external_format = self._convert_to_external(resource) + return self._push_to_external(external_format) + + def handle_external_change(self, change_info) -> bool: + """处理外部系统主动推送的变更""" + return True +``` + +同步策略取决于业务场景: + +- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上 +- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统 + +在工作站 `post_init` 中初始化同步器: + +```python +@not_action +def post_init(self, ros_node): + super().post_init(ros_node) + self.resource_synchronizer = MyResourceSynchronizer(self) + self.resource_synchronizer.sync_from_external() +``` + +### 4. 序列化与持久化(serialize / serialize_state) + +资源类需正确实现序列化,系统据此完成持久化和前端同步。 + +**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用: + +```python +class MyDeck(Deck): + def __init__(self, name, size_x, size_y, size_z, + sites=None, # serialize() 输出的字段 + rotation=None, # serialize() 输出的字段 + barcode=None, # serialize() 输出的字段 + **kwargs): # 兜底:接受所有未知的 serialize 字段 + super().__init__(size_x, size_y, size_z, name) + # ... + + def serialize(self) -> dict: + data = super().serialize() + data["sites"] = [...] # 自定义字段 + return data +``` + +**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复: + +```python +class MyPlate(Plate): + def __init__(self, name, size_x, size_y, size_z, + material_info=None, **kwargs): + super().__init__(name, size_x, size_y, size_z, **kwargs) + self._unilabos_state = {} + if material_info: + self._unilabos_state["Material"] = material_info + + def serialize_state(self) -> Dict[str, Any]: + data = super().serialize_state() + data.update(self._unilabos_state) + return data +``` + +关键要点: + +- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`) +- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等) +- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None) + +### 5. 子物料自动同步 + +子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。 + +### 6. 图文件配置(参考 prcxi_9320_slim.json) + +```json +{ + "nodes": [ + { + "id": "my_station", + "type": "device", + "class": "my_workstation", + "config": { + "deck": { + "_resource_type": "unilabos.resources.my_module:MyDeck", + "_resource_child_name": "my_deck" + }, + "host": "10.20.30.1", + "port": 9999 + } + }, + { + "id": "my_deck", + "parent": "my_station", + "type": "deck", + "class": "", + "children": [], + "config": { + "type": "MyLabDeck", + "size_x": 542, + "size_y": 374, + "size_z": 0, + "category": "deck", + "sites": [ + { + "label": "T1", + "visible": true, + "occupied_by": null, + "position": { "x": 0, "y": 0, "z": 0 }, + "size": { "width": 128.0, "height": 86, "depth": 0 }, + "content_type": ["plate", "tip_rack", "tube_rack", "adaptor"] + } + ] + }, + "data": {} + } + ], + "edges": [] +} +``` + +Deck 节点要点: + +- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`) +- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取) +- `children` 初始为空(由同步器或手动初始化填充) +- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径 + +--- + +## 子设备 + +子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。 + +子设备约束: + +- 图文件中 `parent` 指向工作站 ID +- 在工作站 `children` 数组中列出 + +--- + +## 关键规则 + +1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数 +2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck +3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料 +4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external` +5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用 +6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接 +7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()` +8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup` +9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图 + +--- + +## 验证 + +```bash +# 模块可导入 +python -c "from unilabos.devices.workstation.. import " + +# 启动测试(AST 自动扫描) +unilab -g .json +``` + +--- + +## 现有工作站参考 + +| 工作站 | 驱动类 | 类型 | +| -------------- | ----------------------------- | -------- | +| Protocol 通用 | `ProtocolNode` | Protocol | +| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 | +| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 | + +参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。 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/batch-insert-reagent/SKILL.md b/.cursor/skills/batch-insert-reagent/SKILL.md new file mode 100644 index 00000000..3df13fd3 --- /dev/null +++ b/.cursor/skills/batch-insert-reagent/SKILL.md @@ -0,0 +1,261 @@ +--- +name: batch-insert-reagent +description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent. +--- + +# 批量录入试剂 Skill + +通过云端 API 批量录入试剂信息,支持逐条或批量操作。 + +## 前置条件(缺一不可) + +使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token(任选一种方式): + +```bash +# 方式一:Python 一行生成 +python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" + +# 方式二:手动计算 +# base64(ak:sk) → Authorization: Lab +``` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab " +``` + +**两项全部就绪后才可发起 API 请求。** + +## Session State + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) + +## 请求约定 + +所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回: + +```json +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } +``` + +记住 `data.uuid` 为 `lab_uuid`。 + +### 2. 录入试剂 + +```bash +curl -s -X POST "$BASE/api/v1/lab/reagent" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{ + "lab_uuid": "", + "cas": "", + "name": "<试剂名称>", + "molecular_formula": "<分子式>", + "smiles": "", + "stock_in_quantity": <入库数量>, + "unit": "<单位字符串>", + "supplier": "<供应商>", + "production_date": "<生产日期 ISO 8601>", + "expiry_date": "<过期日期 ISO 8601>" + }' +``` + +返回成功时包含试剂 UUID: + +```json +{"code": 0, "data": {"uuid": "xxx", ...}} +``` + +--- + +## 试剂字段说明 + +| 字段 | 类型 | 必填 | 说明 | 示例 | +| ------------------- | ------ | ---- | ----------------------------- | ------------------------ | +| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` | +| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` | +| `name` | string | 是 | 试剂中文/英文名称 | `"水"` | +| `molecular_formula` | string | 是 | 分子式 | `"H2O"` | +| `smiles` | string | 是 | SMILES 表示 | `"O"` | +| `stock_in_quantity` | number | 是 | 入库数量 | `10` | +| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` | +| `supplier` | string | 否 | 供应商名称 | `"国药集团"` | +| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` | +| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` | + +### unit 单位值 + +| 值 | 单位 | +| ------ | ---- | +| `"mL"` | 毫升 | +| `"L"` | 升 | +| `"g"` | 克 | +| `"kg"` | 千克 | +| `"瓶"` | 瓶 | + +> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。 + +--- + +## 批量录入策略 + +### 方式一:用户提供 JSON 数组 + +用户一次性给出多条试剂数据: + +```json +[ + { + "cas": "7732-18-3", + "name": "水", + "molecular_formula": "H2O", + "smiles": "O", + "stock_in_quantity": 10, + "unit": "mL" + }, + { + "cas": "64-17-5", + "name": "乙醇", + "molecular_formula": "C2H6O", + "smiles": "CCO", + "stock_in_quantity": 5, + "unit": "L" + } +] +``` + +Agent 自动为每条补充 `lab_uuid`、`production_date`、`expiry_date` 等字段后逐条提交。 + +Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。 + +### 方式二:用户逐个描述 + +用户口头描述试剂(如「帮我录入 500mL 的无水乙醇,Sigma 的」),agent 自行补全字段: + +1. 根据名称查找 CAS 号、分子式、SMILES(参考下方速查表或自行推断) +2. 构建完整的请求体 +3. 向用户确认后提交 + +### 方式三:从 CSV/表格批量导入 + +用户提供 CSV 或表格文件路径,agent 读取并解析: + +```bash +# 期望的 CSV 格式(首行为表头) +cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date +7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z +``` + +### 日期格式规则(重要) + +所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。 + +- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"` +- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"` +- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年 + +**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。 + +### 执行与汇报 + +每次 API 调用后: + +1. 检查返回 `code`(0 = 成功) +2. 记录成功/失败数量 +3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」 +4. 如有失败,列出失败的试剂名称和错误信息 + +--- + +## 常见试剂速查表 + +| 名称 | CAS | 分子式 | SMILES | +| --------------------- | --------- | ---------- | ------------------------------------ | +| 水 | 7732-18-3 | H2O | O | +| 乙醇 | 64-17-5 | C2H6O | CCO | +| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O | +| 甲醇 | 67-56-1 | CH4O | CO | +| 丙酮 | 67-64-1 | C3H6O | CC(C)=O | +| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O | +| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O | +| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl | +| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 | +| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O | +| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl | +| 乙腈 | 75-05-8 | C2H3N | CC#N | +| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 | +| 正己烷 | 110-54-3 | C6H14 | CCCCCC | +| 异丙醇 | 67-63-0 | C3H8O | CC(C)O | +| 盐酸 | 7647-01-0 | HCl | Cl | +| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O | +| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O | +| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] | +| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl | +| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O | + +> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。 + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid +- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件) +- [ ] Step 5: 补全缺失字段(CAS、分子式、SMILES 等) +- [ ] Step 6: 向用户确认待录入的试剂列表 +- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid) +- [ ] Step 8: 汇总结果(成功/失败数量及详情) +``` + +--- + +## 完整示例 + +用户说:「帮我录入 3 种试剂:500mL 无水乙醇、1kg 氯化钠、2L 去离子水」 + +Agent 构建的请求序列: + +```json +// 第 1 条 +{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"} + +// 第 2 条 +{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"} + +// 第 3 条 +{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"} +``` diff --git a/.cursor/skills/batch-submit-experiment/SKILL.md b/.cursor/skills/batch-submit-experiment/SKILL.md new file mode 100644 index 00000000..0a368ba3 --- /dev/null +++ b/.cursor/skills/batch-submit-experiment/SKILL.md @@ -0,0 +1,360 @@ +--- +name: batch-submit-experiment +description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态. +--- + +# Uni-Lab 批量提交实验指南 + +通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 + +## 前置条件(缺一不可) + +使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token(任选一种方式): + +```bash +# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic") +python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" + +# 方式二:手动计算 +# base64(ak:sk) → Authorization: Lab +# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代 +``` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic" +AUTH="Authorization: Lab <上面命令输出的 token>" +``` + +### 3. req_device_registry_upload.json(设备注册表) + +**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。** + +**必须先用 Glob 工具搜索文件**,不要直接猜测路径: + +``` +Glob: **/req_device_registry_upload.json +``` + +常见位置(仅供参考,以 Glob 实际结果为准): +- `/unilabos_data/req_device_registry_upload.json` +- `/req_device_registry_upload.json` + +找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。 + +**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。 + +### 4. workflow_uuid(目标工作流) + +用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。 + +**四项全部就绪后才可开始。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态,避免重复询问用户: + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**) +- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择) +- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取) + +## 请求约定 + +所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。 +> +> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回: + +```json +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } +``` + +记住 `data.uuid` 为 `lab_uuid`。 + +### 2. 列出实验室项目(让用户选择项目) + +```bash +curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH" +``` + +返回: + +```json +{ + "code": 0, + "data": { + "items": [ + { + "uuid": "1b3f249a-...", + "name": "bt", + "description": null, + "status": "active", + "created_at": "2026-04-09T14:31:28+08:00" + }, + { + "uuid": "b6366243-...", + "name": "default", + "description": "默认项目", + "status": "active", + "created_at": "2026-03-26T11:13:36+08:00" + } + ] + } +} +``` + +展示 `data.items[]` 中每个项目的 `name` 和 `uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。 + +### 3. 列出可用 workflow + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH" +``` + +返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。 + +### 4. 获取 workflow 模板详情 + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" +``` + +返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取: + +- 每个 action 节点的 `node_uuid` +- 每个节点对应的设备 ID(`resource_template_name`) +- 每个节点的动作名(`node_template_name`) +- 每个节点的现有参数(`param`) + +> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。 + +### 5. 提交实验(创建 notebook) + +```bash +curl -s -X POST "$BASE/api/v1/lab/notebook" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` + +请求体结构: + +```json +{ + "lab_uuid": "", + "project_uuid": "", + "workflow_uuid": "", + "name": "<实验名称>", + "node_params": [ + { + "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], + "datas": [ + { + "node_uuid": "", + "param": {}, + "sample_params": [ + { + "container_uuid": "<容器UUID>", + "sample_value": { + "liquid_names": "<液体名称>", + "volumes": 1000 + } + } + ] + } + ] + } + ] +} +``` + +> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。 + +### 6. 查询 notebook 状态 + +提交成功后,使用返回的 notebook UUID 查询执行状态: + +```bash +curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH" +``` + +提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。 + +--- + +## Notebook 请求体详解 + +### node_params 结构 + +`node_params` 是一个数组,**每个元素代表一轮实验**: + +- 要跑 2 轮 → `node_params` 有 2 个元素 +- 要跑 N 轮 → `node_params` 有 N 个元素 + +### 每轮的字段 + +| 字段 | 类型 | 说明 | +| -------------- | ------------- | ----------------------------------------- | +| `sample_uuids` | array\ | 该轮实验的样品 UUID 数组,无样品时传 `[]` | +| `datas` | array | 该轮中每个 workflow 节点的参数配置 | + +### datas 中每个节点 + +| 字段 | 类型 | 说明 | +| --------------- | ------ | -------------------------------------------- | +| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) | +| `param` | object | 动作参数(根据本地注册表 schema 填写) | +| `sample_params` | array | 样品相关参数(液体名、体积等) | + +### sample_params 中每条 + +| 字段 | 类型 | 说明 | +| ---------------- | ------ | ---------------------------------------------------- | +| `container_uuid` | string | 容器 UUID | +| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` | + +--- + +## 从本地注册表生成 param 模板 + +### 自动方式 — 运行脚本 + +```bash +python scripts/gen_notebook_params.py \ + --auth \ + --base \ + --workflow-uuid \ + [--registry ] \ + [--rounds <轮次数>] \ + [--output <输出文件路径>] +``` + +> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。 + +脚本会: + +1. 调用 workflow detail API 获取所有 action 节点 +2. 读取本地注册表,为每个节点查找对应的 action schema +3. 生成 `notebook_template.json`,包含: + - 完整 `node_params` 骨架 + - 每个节点的 param 字段及类型说明 + - `_schema_info` 辅助信息(不提交,仅供参考) + +### 手动方式 + +如果脚本不可用或注册表不存在: + +1. 调用 API #4 获取 workflow 详情 +2. 找到每个 action 节点的 `node_uuid` +3. 在本地注册表中查找对应设备的 `action_value_mappings`: + ``` + resources[].id == + → resources[].class.action_value_mappings..schema.properties.goal.properties + ``` +4. 将 schema 中的 properties 作为 `param` 的字段模板 +5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值 + +### 注册表结构参考 + +```json +{ + "resources": [ + { + "id": "liquid_handler.prcxi", + "class": { + "module": "unilabos.devices.xxx:ClassName", + "action_value_mappings": { + "transfer_liquid": { + "type": "LiquidHandlerTransfer", + "schema": { + "properties": { + "goal": { + "properties": { + "asp_vols": { + "type": "array", + "items": { "type": "number" } + }, + "sources": { "type": "array" } + }, + "required": ["asp_vols", "sources"] + } + } + }, + "goal_default": {} + } + } + } + } + ] +} +``` + +`param` 填写时,使用 `goal.properties` 中的字段名和类型。 + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid +- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid +- [ ] Step 5: 确认 workflow_uuid(用户提供或从 GET #3 列表选择) +- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名 +- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json +- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板 +- [ ] Step 9: 引导用户填写每轮的参数(sample_uuids、param、sample_params) +- [ ] Step 10: 构建完整请求体(含 project_uuid)→ POST /lab/notebook 提交 +- [ ] Step 11: 检查返回结果,记录 notebook UUID +- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度 +``` + +--- + +## 常见问题 + +### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗? + +是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。 + +### Q: 多轮实验的参数完全不同吗? + +通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。 + +### Q: 如何获取 sample_uuids 和 container_uuid? + +这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。 diff --git a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py new file mode 100644 index 00000000..a6cbea86 --- /dev/null +++ b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。 + +用法: + python gen_notebook_params.py --auth --base --workflow-uuid [选项] + +选项: + --auth Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀) + --base API 基础 URL(如 https://leap-lab.test.bohrium.com) + --workflow-uuid 目标 workflow 的 UUID + --registry 本地注册表文件路径(默认自动搜索) + --rounds 实验轮次数(默认 1) + --output 输出模板文件路径(默认 notebook_template.json) + --dump-response 打印 workflow detail API 的原始响应(调试用) + +示例: + python gen_notebook_params.py \\ + --auth YTFmZDlkNGUtxxxx \\ + --base https://leap-lab.test.bohrium.com \\ + --workflow-uuid abc-123-def \\ + --rounds 2 +""" +import copy +import json +import os +import sys +from datetime import datetime +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + +REGISTRY_FILENAME = "req_device_registry_upload.json" + + +def find_registry(explicit_path=None): + """查找本地注册表文件,逻辑同 extract_device_actions.py""" + if explicit_path: + if os.path.isfile(explicit_path): + return explicit_path + if os.path.isdir(explicit_path): + fp = os.path.join(explicit_path, REGISTRY_FILENAME) + if os.path.isfile(fp): + return fp + print(f"警告: 指定的注册表路径不存在: {explicit_path}") + return None + + candidates = [ + os.path.join("unilabos_data", REGISTRY_FILENAME), + REGISTRY_FILENAME, + ] + for c in candidates: + if os.path.isfile(c): + return c + + script_dir = os.path.dirname(os.path.abspath(__file__)) + workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", "..")) + for c in candidates: + path = os.path.join(workspace_root, c) + if os.path.isfile(path): + return path + + cwd = os.getcwd() + for _ in range(5): + parent = os.path.dirname(cwd) + if parent == cwd: + break + cwd = parent + for c in candidates: + path = os.path.join(cwd, c) + if os.path.isfile(path): + return path + return None + + +def load_registry(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def build_registry_index(registry_data): + """构建 device_id → action_value_mappings 的索引""" + index = {} + for res in registry_data.get("resources", []): + rid = res.get("id", "") + avm = res.get("class", {}).get("action_value_mappings", {}) + if rid and avm: + index[rid] = avm + return index + + +def flatten_goal_schema(action_data): + """从 action_value_mappings 条目中提取 goal 层的 schema""" + schema = action_data.get("schema", {}) + goal_schema = schema.get("properties", {}).get("goal", {}) + return goal_schema if goal_schema else schema + + +def build_param_template(goal_schema): + """根据 goal schema 生成 param 模板,含类型标注""" + properties = goal_schema.get("properties", {}) + required = set(goal_schema.get("required", [])) + template = {} + for field_name, field_def in properties.items(): + if field_name == "unilabos_device_id": + continue + ftype = field_def.get("type", "any") + default = field_def.get("default") + if default is not None: + template[field_name] = default + elif ftype == "string": + template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})" + elif ftype == "number" or ftype == "integer": + template[field_name] = 0 + elif ftype == "boolean": + template[field_name] = False + elif ftype == "array": + template[field_name] = [] + elif ftype == "object": + template[field_name] = {} + else: + template[field_name] = f"$TODO ({ftype})" + return template + + +def fetch_workflow_detail(base_url, auth_token, workflow_uuid): + """调用 workflow detail API""" + url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}" + req = Request(url, method="GET") + req.add_header("Authorization", f"Lab {auth_token}") + try: + with urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + print(f"API 错误 {e.code}: {body}") + return None + except URLError as e: + print(f"网络错误: {e.reason}") + return None + + +def extract_nodes_from_response(response): + """ + 从 workflow detail 响应中提取 action 节点列表。 + 适配多种可能的响应格式。 + + 返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...] + """ + data = response.get("data", response) + + search_keys = ["nodes", "workflow_nodes", "node_list", "steps"] + nodes_raw = None + for key in search_keys: + if key in data and isinstance(data[key], list): + nodes_raw = data[key] + break + + if nodes_raw is None: + if isinstance(data, list): + nodes_raw = data + else: + for v in data.values(): + if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict): + nodes_raw = v + break + + if not nodes_raw: + print("警告: 未能从响应中提取节点列表") + print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__) + return [] + + result = [] + for node in nodes_raw: + if not isinstance(node, dict): + continue + + node_uuid = ( + node.get("uuid") + or node.get("node_uuid") + or node.get("id") + or "" + ) + resource_name = ( + node.get("resource_template_name") + or node.get("device_id") + or node.get("resource_name") + or node.get("device_name") + or "" + ) + template_name = ( + node.get("node_template_name") + or node.get("action_name") + or node.get("template_name") + or node.get("action") + or node.get("name") + or "" + ) + existing_param = node.get("param", {}) or {} + + if node_uuid: + result.append((node_uuid, resource_name, template_name, existing_param)) + + return result + + +def generate_template(nodes, registry_index, rounds): + """生成 notebook 提交模板""" + node_params = [] + schema_info = {} + + datas_template = [] + for node_uuid, resource_name, template_name, existing_param in nodes: + param_template = {} + matched = False + + if resource_name and template_name and resource_name in registry_index: + avm = registry_index[resource_name] + if template_name in avm: + goal_schema = flatten_goal_schema(avm[template_name]) + param_template = build_param_template(goal_schema) + goal_default = avm[template_name].get("goal_default", {}) + if goal_default: + for k, v in goal_default.items(): + if k in param_template and v is not None: + param_template[k] = v + matched = True + + schema_info[node_uuid] = { + "device_id": resource_name, + "action_name": template_name, + "action_type": avm[template_name].get("type", ""), + "schema_properties": list(goal_schema.get("properties", {}).keys()), + "required": goal_schema.get("required", []), + } + + if not matched and existing_param: + param_template = existing_param + + if not matched and not existing_param: + schema_info[node_uuid] = { + "device_id": resource_name, + "action_name": template_name, + "warning": "未在本地注册表中找到匹配的 action schema", + } + + datas_template.append({ + "node_uuid": node_uuid, + "param": param_template, + "sample_params": [ + { + "container_uuid": "$TODO_CONTAINER_UUID", + "sample_value": { + "liquid_names": "$TODO_LIQUID_NAME", + "volumes": 0, + }, + } + ], + }) + + for i in range(rounds): + node_params.append({ + "sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}", + "datas": copy.deepcopy(datas_template), + }) + + return { + "lab_uuid": "$TODO_LAB_UUID", + "project_uuid": "$TODO_PROJECT_UUID", + "workflow_uuid": "$TODO_WORKFLOW_UUID", + "name": "$TODO_EXPERIMENT_NAME", + "node_params": node_params, + "_schema_info(仅参考,提交时删除)": schema_info, + } + + +def parse_args(argv): + """简单的参数解析""" + opts = { + "auth": None, + "base": None, + "workflow_uuid": None, + "registry": None, + "rounds": 1, + "output": "notebook_template.json", + "dump_response": False, + } + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--auth" and i + 1 < len(argv): + opts["auth"] = argv[i + 1] + i += 2 + elif arg == "--base" and i + 1 < len(argv): + opts["base"] = argv[i + 1].rstrip("/") + i += 2 + elif arg == "--workflow-uuid" and i + 1 < len(argv): + opts["workflow_uuid"] = argv[i + 1] + i += 2 + elif arg == "--registry" and i + 1 < len(argv): + opts["registry"] = argv[i + 1] + i += 2 + elif arg == "--rounds" and i + 1 < len(argv): + opts["rounds"] = int(argv[i + 1]) + i += 2 + elif arg == "--output" and i + 1 < len(argv): + opts["output"] = argv[i + 1] + i += 2 + elif arg == "--dump-response": + opts["dump_response"] = True + i += 1 + else: + print(f"未知参数: {arg}") + i += 1 + return opts + + +def main(): + opts = parse_args(sys.argv[1:]) + + if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]: + print("用法:") + print(" python gen_notebook_params.py --auth --base --workflow-uuid [选项]") + print() + print("必需参数:") + print(" --auth Lab token(base64(ak:sk))") + print(" --base API 基础 URL") + print(" --workflow-uuid 目标 workflow UUID") + print() + print("可选参数:") + print(" --registry 注册表文件路径(默认自动搜索)") + print(" --rounds 实验轮次数(默认 1)") + print(" --output 输出文件路径(默认 notebook_template.json)") + print(" --dump-response 打印 API 原始响应") + sys.exit(1) + + # 1. 查找并加载本地注册表 + registry_path = find_registry(opts["registry"]) + registry_index = {} + if registry_path: + mtime = os.path.getmtime(registry_path) + gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + print(f"注册表: {registry_path} (生成时间: {gen_time})") + registry_data = load_registry(registry_path) + registry_index = build_registry_index(registry_data) + print(f"已索引 {len(registry_index)} 个设备的 action schemas") + else: + print("警告: 未找到本地注册表,将跳过 param 模板生成") + print(" 提交时需要手动填写各节点的 param 字段") + + # 2. 获取 workflow 详情 + print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}") + response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"]) + if not response: + print("错误: 无法获取 workflow 详情") + sys.exit(1) + + if opts["dump_response"]: + print("\n=== API 原始响应 ===") + print(json.dumps(response, indent=2, ensure_ascii=False)[:5000]) + print("=== 响应结束(截断至 5000 字符) ===\n") + + # 3. 提取节点 + nodes = extract_nodes_from_response(response) + if not nodes: + print("错误: 未能从 workflow 中提取任何 action 节点") + print("请使用 --dump-response 查看原始响应结构") + sys.exit(1) + + print(f"\n找到 {len(nodes)} 个 action 节点:") + print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}") + print(" " + "-" * 110) + for node_uuid, resource_name, template_name, _ in nodes: + matched = "✓" if (resource_name in registry_index and + template_name in registry_index.get(resource_name, {})) else "✗" + print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}") + + # 4. 生成模板 + template = generate_template(nodes, registry_index, opts["rounds"]) + template["workflow_uuid"] = opts["workflow_uuid"] + + output_path = opts["output"] + with open(output_path, "w", encoding="utf-8") as f: + json.dump(template, f, indent=2, ensure_ascii=False) + print(f"\n模板已写入: {output_path}") + print(f" 轮次数: {opts['rounds']}") + print(f" 节点数/轮: {len(nodes)}") + print() + print("下一步:") + print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值") + print(" 2. 删除 _schema_info 字段(仅供参考)") + print(" 3. 使用 POST /api/v1/lab/notebook 提交") + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index 8f524141..c4fc7a10 100644 --- a/.cursor/skills/create-device-skill/SKILL.md +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config 决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取: -| `--addr` 值 | BASE URL | -|-------------|----------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | -| 其他自定义 URL | 直接使用该 URL | +| `--addr` 值 | BASE URL | +| -------------- | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | +| 其他自定义 URL | 直接使用该 URL | #### 必备项 ③:req_device_registry_upload.json(设备注册表) @@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config **推断 working_dir**(即 `unilabos_data` 所在目录): -| 条件 | working_dir 取值 | -|------|------------------| +| 条件 | working_dir 取值 | +| -------------------- | -------------------------------------------------------- | | 传了 `--working_dir` | `/unilabos_data/`(若子目录已存在则直接用) | -| 仅传了 `--config` | `/unilabos_data/` | -| 都没传 | `<当前工作目录>/unilabos_data/` | +| 仅传了 `--config` | `/unilabos_data/` | +| 都没传 | `<当前工作目录>/unilabos_data/` | **按优先级搜索文件**: @@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config python ./scripts/extract_device_actions.py --registry <找到的文件路径> ``` -#### 完整示例 - -用户提供: - -``` ---ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd ---sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b ---addr test ---port 8003 ---disable_browser -``` - -从中提取: -- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."` -- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com` -- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间 -- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi` - **四项全部就绪后才进入 Step 1。** ### Step 1 — 列出可用设备 @@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski 脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。 每个 action 生成一个 JSON 文件,包含: + - `type` — 作为 API 调用的 `action_type` - `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义) - `goal` — goal 字段映射(含占位符 `$placeholder`) @@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski ### Step 3 — 写 action-index.md -按模板为每个 action 写条目: +按模板为每个 action 写条目(**必须包含 `action_type`**): ```markdown ### `` <用途描述(一句话)> +- **action_type**: `<从 actions/.json 的 type 字段获取>` - **Schema**: [`actions/.json`](actions/.json) - **核心参数**: `param1`, `param2`(从 schema.required 获取) - **可选参数**: `param3`, `param4` @@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski ``` 描述规则: + +- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住 - 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容) - 从 `schema.required` 区分核心/可选参数 - 按功能分类(移液、枪头、外设等) @@ -158,12 +144,14 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski - `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device) - `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点) - `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找) + - `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name) - array 类型字段 → `[{id, name, uuid}, ...]` - 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径 ### Step 4 — 写 SKILL.md -直接复用 `unilab-device-api` 的 API 模板(10 个 endpoint),修改: +直接复用 `unilab-device-api` 的 API 模板,修改: + - 设备名称 - Action 数量 - 目录列表 @@ -171,43 +159,96 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski - **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab `(不要硬编码 `Api` 类型的 key) - **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义 - **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名) +- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件 API 模板结构: ```markdown ## 设备信息 + - device_id, Python 源码路径, 设备类名 ## 前置条件(缺一不可) + - ak/sk → AUTH, --addr → BASE URL -## Session State -- lab_uuid(通过 API #1 自动匹配,不要问用户), device_name +## 请求约定 -## API Endpoints (10 个) -# 注意: -# - #1 获取 lab 列表 + 自动匹配 lab_uuid(遍历 is_admin 的 lab, -# 调用 /lab/info/{uuid} 比对 access_key == ak) -# - #2 创建工作流用 POST /lab/workflow -# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid} +- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名) + +## Session State + +- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name + +## API Endpoints + +# - #1 GET /edge/lab/info → 直接拿到 lab_uuid + +# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户 + +# - #3 创建节点 POST /edge/workflow/node + +# body: {workflow_uuid, resource_template_name: "", node_template_name: ""} + +# - #4 删除节点 DELETE /lab/workflow/nodes + +# - #5 更新节点参数 PATCH /lab/workflow/node + +# - #6 查询节点 handles POST /lab/workflow/node-handles + +# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid + +# - #7 批量创建边 POST /lab/workflow/edges + +# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]} + +# - #8 启动工作流 POST /lab/workflow/{uuid}/run + +# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/.json 的 type 字段获取,传错会导致任务永远卡住) + +# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid} + +# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action + +# - #12 获取资源树 GET /lab/material/download/{lab_uuid} + +# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid} + +# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles + +# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name= + +# 返回 res_template_uuid,用于 #15 创建物料时的必填字段 + +# - #15 创建物料节点 POST /edge/material/node + +# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...} + +# - #16 更新物料节点 PUT /edge/material/node + +# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...} ## Placeholder Slot 填写规则 + - unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"} - unilabos_devices → DeviceSlot → "/parent/device" 路径字符串 - unilabos_nodes → NodeSlot → "/parent/node" 路径字符串 - unilabos_class → ClassSlot → "class_name" 字符串 +- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组 - 特例:create_resource 的 res_id 允许填不存在的路径 - 列出本设备所有 Slot 字段、类型及含义 ## 渐进加载策略 + ## 完整工作流 Checklist ``` ### Step 5 — 验证 检查文件完整性: -- [ ] `SKILL.md` 包含 10 个 API endpoint -- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表 + +- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理) +- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表 - [ ] `action-index.md` 列出所有 action 并有描述 - [ ] `actions/` 目录中每个 action 有对应 JSON 文件 - [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段 @@ -249,71 +290,202 @@ API 模板结构: ``` > **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。 -> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段。 +> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段,PATCH 更新时直接修改 `param` 即可。 ## Placeholder Slot 类型体系 -`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式: +`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式: -| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | -|---------------|-----------|---------|---------| -| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) | -| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 | -| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 | -| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name | +| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | +| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name | +| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 | ### ResourceSlot(`unilabos_resources`) 最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等): +- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}` +- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]` +- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料 + +> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。 + +### DeviceSlot / NodeSlot / ClassSlot + +- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点 +- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选 +- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找 + +### FormulationSlot(`unilabos_formulation`) + +描述**液体配方**:向哪些容器中加入哪些液体及体积。 + ```json -{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"} +[ + { + "sample_uuid": "", + "well_name": "bottle_A1", + "liquids": [{ "name": "LiPF6", "volume": 0.6 }] + } +] ``` -- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}` -- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]` -- `id` 本身是从 parent 计算的路径格式 -- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置) +- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径) +- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL) +- `sample_uuid` — 样品 UUID,无样品传 `""` +- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息 -> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。 - -### DeviceSlot(`unilabos_devices`) - -填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径: - -``` -"/host_node" -"/bioyond_cell/reaction_station" -``` - -- 只填路径字符串,不需要 `{id, uuid}` 对象 -- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备) - -### NodeSlot(`unilabos_nodes`) - -范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**: - -``` -"/PRCXI/PRCXI_Deck" -``` - -- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`) - -### ClassSlot(`unilabos_class`) - -填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找: - -``` -"container" -``` - -### 通过 API #10 获取资源树 +### 通过 API #12 获取资源树 ```bash curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" ``` -注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。 +注意 `lab_uuid` 在路径中(不是查询参数)。返回结构: + +```json +{ + "code": 0, + "data": { + "nodes": [ + {"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""}, + {"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""}, + {"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"} + ], + "edges": [...] + } +} +``` + +- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent` +- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等) +- `parent` 为父节点名称(空字符串表示顶级) +- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点 +- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid` + +## 物料管理 API + +设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。 + +典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。 + +### API #14 — 按名称查询物料模板 + +创建物料前,需要先获取物料模板的 UUID。通过模板名称查询: + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" +``` + +| 参数 | 必填 | 说明 | +| ---------- | ------ | -------------------------------- | +| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) | +| `name` | **是** | 物料模板名称(如 `"container"`) | + +返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。 + +模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。 + +### API #15 — 创建物料节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` + +请求体: + +```json +{ + "res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "my_custom_bottle", + "display_name": "自定义瓶子", + "parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": "", + "init_param_data": {}, + "schema": {}, + "data": { + "liquids": [["water", 1000, "uL"]], + "max_volume": 50000 + }, + "plate_well_datas": {}, + "plate_reagent_datas": {}, + "pose": {}, + "model": {} +} +``` + +| 字段 | 必填 | 类型 | 数据来源 | 说明 | +| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- | +| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID | +| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 | +| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) | +| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 | +| `type` | 否 | string | 从模板继承 | 节点类型 | +| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 | +| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 | +| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) | +| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 | +| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 | +| `pose` | 否 | object | 用户指定 | 位姿信息 | +| `model` | 否 | object | 用户指定 | 3D 模型信息 | + +#### container 的 `data` 格式 + +> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。 + +```json +{ + "liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]], + "max_volume": 50000 +} +``` + +- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]` +- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL + +### API #16 — 更新物料节点 + +```bash +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` + +请求体: + +```json +{ + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "display_name": "新显示名称", + "description": "新描述", + "init_param_data": {}, + "data": {}, + "pose": {}, + "schema": {}, + "extra": {} +} +``` + +| 字段 | 必填 | 类型 | 数据来源 | 说明 | +| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- | +| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 | +| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 | +| `display_name` | 否 | string | 用户指定 | 更新显示名称 | +| `description` | 否 | string | 用户指定 | 更新描述 | +| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 | +| `data` | 否 | object | 用户指定 | 更新节点数据 | +| `pose` | 否 | object | 用户指定 | 更新位姿 | +| `schema` | 否 | object | 用户指定 | 更新 schema | +| `extra` | 否 | object | 用户指定 | 更新扩展数据 | + +> 只传需要更新的字段,未传的字段保持不变。 ## 最终目录结构 diff --git a/.cursor/skills/host-node/SKILL.md b/.cursor/skills/host-node/SKILL.md new file mode 100644 index 00000000..06025355 --- /dev/null +++ b/.cursor/skills/host-node/SKILL.md @@ -0,0 +1,251 @@ +--- +name: host-node +description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation. +--- + +# Host Node API Skill + +## 设备信息 + +- **device_id**: `host_node` +- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py` +- **设备类**: `HostNode` +- **动作数**: 4(`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`) + +## 前置条件(缺一不可) + +使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab ` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab " +``` + +**两项全部就绪后才可发起 API 请求。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态,避免重复询问用户: + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `device_name` — `host_node` + +## 请求约定 + +所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。 + +### 2. 创建工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"<名称>","lab_uuid":"","description":"<描述>"}' +``` + +返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid` + +### 3. 创建节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","resource_template_name":"host_node","node_template_name":""}' +``` + +- `resource_template_name` 固定为 `host_node` +- `node_template_name` — action 名称(如 `create_resource`, `test_latency`) + +### 4. 删除节点 + +```bash +curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":[""],"workflow_uuid":""}' +``` + +### 5. 更新节点参数 + +```bash +curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","uuid":"","param":{...}}' +``` + +`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。 + +### 6. 查询节点 handles + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":["",""]}' +``` + +### 7. 批量创建边 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"edges":[{"source_node_uuid":"","target_node_uuid":"","source_handle_uuid":"","target_handle_uuid":""}]}' +``` + +### 8. 启动工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow//run" -H "$AUTH" +``` + +### 9. 运行设备单动作 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"lab_uuid":"","device_id":"host_node","action":"","action_type":"","param":{...}}' +``` + +`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。 + +> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/.json` 的 `type` 字段获取。 + +#### action_type 速查表 + +| action | action_type | +|--------|-------------| +| `test_latency` | `UniLabJsonCommand` | +| `create_resource` | `ResourceCreateFromOuterEasy` | +| `auto-test_resource` | `UniLabJsonCommand` | +| `manual_confirm` | `UniLabJsonCommand` | + +### 10. 查询任务状态 + +```bash +curl -s -X GET "$BASE/api/v1/lab/mcp/task/" -H "$AUTH" +``` + +### 11. 运行工作流单节点 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuid":""}' +``` + +### 12. 获取资源树(物料信息) + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` + +注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。 + +### 13. 获取工作流模板详情 + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" +``` + +> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。 + +### 14. 按名称查询物料模板 + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" +``` + +返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。 + +### 15. 创建物料节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"res_template_uuid":"","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}' +``` + +### 16. 更新物料节点 + +```bash +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}' +``` + +--- + +## Placeholder Slot 填写规则 + +| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 | +| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 | + +### host_node 设备的 Slot 字段表 + +| Action | 字段 | Slot 类型 | 说明 | +| ----------------- | ----------- | ------------ | ------------------------------ | +| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) | +| `create_resource` | `device_id` | DeviceSlot | 归属设备 | +| `create_resource` | `parent` | NodeSlot | 父节点路径 | +| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` | +| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 | +| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 | +| `auto-test_resource` | `device` | DeviceSlot | 测试设备 | +| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 | + +--- + +## 渐进加载策略 + +1. **SKILL.md**(本文件)— API 端点 + session state 管理 +2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数 +3. **[actions/\.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid +- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料 +- [ ] Step 3: 读 action-index.md 确定要用的 action 名 +- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接 +- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param +- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段 +- [ ] Step 7: 更新节点参数 (PATCH #5) +- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid +- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点 +- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11) +- [ ] Step 11: 查询任务状态 (GET #10) 确认完成 +``` diff --git a/.cursor/skills/host-node/action-index.md b/.cursor/skills/host-node/action-index.md new file mode 100644 index 00000000..c931bc53 --- /dev/null +++ b/.cursor/skills/host-node/action-index.md @@ -0,0 +1,58 @@ +# Action Index — host_node + +4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 + +--- + +## 资源管理 + +### `create_resource` + +在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体 + +- **action_type**: `ResourceCreateFromOuterEasy` +- **Schema**: [`actions/create_resource.json`](actions/create_resource.json) +- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck` +- **占位符字段**: + - `res_id` — **ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径) + - `device_id` — **DeviceSlot**,填路径字符串如 `"/host_node"` + - `parent` — **NodeSlot**,填路径字符串如 `"/workstation/deck"` + - `class_name` — **ClassSlot**,填类名如 `"container"` + +### `auto-test_resource` + +测试资源系统,返回当前资源树和设备列表 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/test_resource.json`](actions/test_resource.json) +- **可选参数**: `resource`, `resources`, `device`, `devices` +- **占位符字段**: + - `resource` — **ResourceSlot**,单个物料节点 `{id, name, uuid}` + - `resources` — **ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]` + - `device` — **DeviceSlot**,设备路径字符串 + - `devices` — **DeviceSlot**,设备路径字符串 + +--- + +## 系统工具 + +### `test_latency` + +测试设备通信延迟,返回 RTT、时间差、任务延迟等指标 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/test_latency.json`](actions/test_latency.json) +- **参数**: 无(零参数调用) + +--- + +## 人工确认 + +### `manual_confirm` + +创建人工确认节点,等待用户手动确认后继续 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) +- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表) +- **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型 diff --git a/.cursor/skills/host-node/actions/create_resource.json b/.cursor/skills/host-node/actions/create_resource.json new file mode 100644 index 00000000..c7f16d5b --- /dev/null +++ b/.cursor/skills/host-node/actions/create_resource.json @@ -0,0 +1,93 @@ +{ + "type": "ResourceCreateFromOuterEasy", + "goal": { + "res_id": "res_id", + "class_name": "class_name", + "parent": "parent", + "device_id": "device_id", + "bind_locations": "bind_locations", + "liquid_input_slot": "liquid_input_slot[]", + "liquid_type": "liquid_type[]", + "liquid_volume": "liquid_volume[]", + "slot_on_deck": "slot_on_deck" + }, + "schema": { + "type": "object", + "properties": { + "res_id": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "class_name": { + "type": "string" + }, + "parent": { + "type": "string" + }, + "bind_locations": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "bind_locations", + "additionalProperties": false + }, + "liquid_input_slot": { + "type": "array", + "items": { + "type": "integer" + } + }, + "liquid_type": { + "type": "array", + "items": { + "type": "string" + } + }, + "liquid_volume": { + "type": "array", + "items": { + "type": "number" + } + }, + "slot_on_deck": { + "type": "string" + } + }, + "required": [], + "_unilabos_placeholder_info": { + "res_id": "unilabos_resources", + "device_id": "unilabos_devices", + "parent": "unilabos_nodes", + "class_name": "unilabos_class" + } + }, + "goal_default": {}, + "placeholder_keys": { + "res_id": "unilabos_resources", + "device_id": "unilabos_devices", + "parent": "unilabos_nodes", + "class_name": "unilabos_class" + } +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/manual_confirm.json b/.cursor/skills/host-node/actions/manual_confirm.json new file mode 100644 index 00000000..ee0b220e --- /dev/null +++ b/.cursor/skills/host-node/actions/manual_confirm.json @@ -0,0 +1,32 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "timeout_seconds": "timeout_seconds", + "assignee_user_ids": "assignee_user_ids" + }, + "schema": { + "type": "object", + "properties": { + "timeout_seconds": { + "type": "integer" + }, + "assignee_user_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "timeout_seconds", + "assignee_user_ids" + ], + "_unilabos_placeholder_info": { + "assignee_user_ids": "unilabos_manual_confirm" + } + }, + "goal_default": {}, + "placeholder_keys": { + "assignee_user_ids": "unilabos_manual_confirm" + } +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/test_latency.json b/.cursor/skills/host-node/actions/test_latency.json new file mode 100644 index 00000000..0fbd448f --- /dev/null +++ b/.cursor/skills/host-node/actions/test_latency.json @@ -0,0 +1,11 @@ +{ + "type": "UniLabJsonCommand", + "goal": {}, + "schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/test_resource.json b/.cursor/skills/host-node/actions/test_resource.json new file mode 100644 index 00000000..e9459fc7 --- /dev/null +++ b/.cursor/skills/host-node/actions/test_resource.json @@ -0,0 +1,255 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "resource": "resource", + "resources": "resources", + "device": "device", + "devices": "devices" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "resources": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resources" + }, + "type": "array" + }, + "device": { + "type": "string", + "description": "device reference" + }, + "devices": { + "type": "string", + "description": "device reference" + } + }, + "required": [], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "resources": "unilabos_resources", + "device": "unilabos_devices", + "devices": "unilabos_devices" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "resources": "unilabos_resources", + "device": "unilabos_devices", + "devices": "unilabos_devices" + } +} \ No newline at end of file diff --git a/.cursor/skills/submit-agent-result/SKILL.md b/.cursor/skills/submit-agent-result/SKILL.md new file mode 100644 index 00000000..b94a0aaf --- /dev/null +++ b/.cursor/skills/submit-agent-result/SKILL.md @@ -0,0 +1,284 @@ +--- +name: submit-agent-result +description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果. +--- + +# Uni-Lab 提交历史实验记录指南 + +通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 + +## 前置条件(缺一不可) + +使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token: + +```bash +# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic" +python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" +``` + +输出即为 token 值,拼接为 `Authorization: Lab `(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。 + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic" +AUTH="Authorization: Lab <上面命令输出的 token>" +``` + +### 3. notebook_uuid(**必须询问用户**) + +**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」 + +notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。 + +如果用户不记得,可提示: + +- 查看之前的对话记录中创建 notebook 时返回的 UUID +- 或通过平台页面查找对应的 notebook + +**绝不能跳过此步骤,没有 notebook_uuid 无法提交。** + +### 4. 实验结果数据 + +用户需要提供实验结果数据,支持以下方式: + +| 方式 | 说明 | +| --------- | ----------------------------------------------- | +| JSON 文件 | 直接作为 `agent_result` 的内容合并 | +| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 | +| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON | + +**四项全部就绪后才可开始。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态: + +- `lab_uuid` — 实验室 UUID(通过 API #1 自动获取,**不需要问用户**) +- `notebook_uuid` — 目标 notebook UUID(**必须询问用户**) + +## 请求约定 + +所有请求使用 `curl -s`,PUT 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。 +> +> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回: + +```json +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } +``` + +记住 `data.uuid` 为 `lab_uuid`。 + +### 2. 提交实验结果(agent_result) + +```bash +curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` + +请求体结构: + +```json +{ + "notebook_uuid": "", + "agent_result": { + "": "", + "": 123, + "": {"a": 1, "b": 2}, + "": [{"col1": "v1", "col2": "v2"}, ...] + } +} +``` + +> **注意**:HTTP 方法是 **PUT**(不是 POST)。 + +#### 必要字段 + +| 字段 | 类型 | 说明 | +| --------------- | ------------- | ------------------------------------------- | +| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 | +| `agent_result` | object | 实验结果数据,任意 JSON 对象 | + +#### agent_result 内容格式 + +`agent_result` 接受**任意 JSON 对象**,常见格式: + +**简单键值对**: + +```json +{ + "avg_rtt_ms": 12.5, + "status": "success", + "test_count": 5 +} +``` + +**包含嵌套结构**: + +```json +{ + "summary": { "total": 100, "passed": 98, "failed": 2 }, + "measurements": [ + { "sample_id": "S001", "value": 3.14, "unit": "mg/mL" }, + { "sample_id": "S002", "value": 2.71, "unit": "mg/mL" } + ] +} +``` + +**从 CSV 文件导入**(脚本自动转换): + +```json +{ + "experiment_data": [ + { "温度": 25, "压力": 101.3, "产率": 0.85 }, + { "温度": 30, "压力": 101.3, "产率": 0.91 } + ] +} +``` + +--- + +## 整合脚本 + +本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。 + +### 用法 + +```bash +python scripts/prepare_agent_result.py \ + --notebook-uuid \ + --files data1.json data2.csv \ + [--auth ] \ + [--base ] \ + [--submit] \ + [--output ] +``` + +| 参数 | 必选 | 说明 | +| ----------------- | ---------- | ----------------------------------------------- | +| `--notebook-uuid` | 是 | 目标 notebook UUID | +| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) | +| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) | +| `--base` | 提交时必选 | API base URL | +| `--submit` | 否 | 加上此标志则直接提交到云端 | +| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) | + +### 文件合并规则 + +| 文件类型 | 合并方式 | +| --------------------- | -------------------------------------------- | +| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 | +| `.json`(list/other) | 以文件名为 key 放入 `agent_result` | +| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 | + +多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。 + +### 示例 + +```bash +# 仅生成请求体文件(不提交) +python scripts/prepare_agent_result.py \ + --notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \ + --files results.json measurements.csv + +# 生成并直接提交 +python scripts/prepare_agent_result.py \ + --notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \ + --files results.json \ + --auth YTFmZDlkNGUt... \ + --base https://leap-lab.test.bohrium.com \ + --submit +``` + +--- + +## 手动构建方式 + +如果不使用脚本,也可手动构建请求体: + +1. 将实验结果数据组装为 JSON 对象 +2. 写入临时文件: + +```json +{ + "notebook_uuid": "", + "agent_result": { ... } +} +``` + +3. 用 curl 提交: + +```bash +curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '@tmp_body.json' +``` + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid +- [ ] Step 4: **询问用户** notebook_uuid(必须,不可跳过) +- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据) +- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体 +- [ ] Step 7: PUT /lab/notebook/agent-result 提交 +- [ ] Step 8: 检查返回结果,确认提交成功 +``` + +--- + +## 常见问题 + +### Q: notebook_uuid 从哪里获取? + +从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。 + +### Q: agent_result 有固定的 schema 吗? + +没有严格 schema,接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。 + +### Q: 可以多次提交同一个 notebook 的结果吗? + +可以,后续提交会覆盖之前的 agent_result。 + +### Q: 认证方式是 Lab 还是 Api? + +本指南统一使用 `Authorization: Lab ` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 diff --git a/.cursor/skills/submit-agent-result/scripts/prepare_agent_result.py b/.cursor/skills/submit-agent-result/scripts/prepare_agent_result.py new file mode 100644 index 00000000..2ee4e17f --- /dev/null +++ b/.cursor/skills/submit-agent-result/scripts/prepare_agent_result.py @@ -0,0 +1,133 @@ +""" +读取实验结果文件(JSON / CSV),整合为 agent_result 请求体并可选提交。 + +用法: + python prepare_agent_result.py \ + --notebook-uuid \ + --files data1.json data2.csv \ + [--auth ] \ + [--base ] \ + [--submit] \ + [--output ] + +支持的输入文件格式: + - .json → 直接作为 dict 合并 + - .csv → 转为 {"filename": [row_dict, ...]} 格式 +""" + +import argparse +import base64 +import csv +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, List + + +def read_json_file(filepath: str) -> Dict[str, Any]: + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + + +def read_csv_file(filepath: str) -> List[Dict[str, Any]]: + rows = [] + with open(filepath, "r", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + for row in reader: + converted = {} + for k, v in row.items(): + try: + converted[k] = int(v) + except (ValueError, TypeError): + try: + converted[k] = float(v) + except (ValueError, TypeError): + converted[k] = v + rows.append(converted) + return rows + + +def merge_files(filepaths: List[str]) -> Dict[str, Any]: + """将多个文件合并为一个 agent_result dict""" + merged: Dict[str, Any] = {} + for fp in filepaths: + path = Path(fp) + ext = path.suffix.lower() + key = path.stem + + if ext == ".json": + data = read_json_file(fp) + if isinstance(data, dict): + merged.update(data) + else: + merged[key] = data + elif ext == ".csv": + merged[key] = read_csv_file(fp) + else: + print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr) + + return merged + + +def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]: + return { + "notebook_uuid": notebook_uuid, + "agent_result": agent_result, + } + + +def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]: + try: + import requests + except ImportError: + print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr) + sys.exit(1) + + url = f"{base}/api/v1/lab/notebook/agent-result" + headers = { + "Content-Type": "application/json", + "Authorization": f"Lab {auth}", + } + resp = requests.put(url, json=body, headers=headers, timeout=30) + return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text} + + +def main(): + parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体") + parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID") + parser.add_argument("--files", nargs="+", required=True, help="输入文件路径(JSON / CSV)") + parser.add_argument("--auth", help="Lab token(base64(ak:sk))") + parser.add_argument("--base", help="API base URL") + parser.add_argument("--submit", action="store_true", help="直接提交到云端") + parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径") + + args = parser.parse_args() + + for fp in args.files: + if not os.path.exists(fp): + print(f"[错误] 文件不存在: {fp}", file=sys.stderr) + sys.exit(1) + + agent_result = merge_files(args.files) + body = build_request_body(args.notebook_uuid, agent_result) + + with open(args.output, "w", encoding="utf-8") as f: + json.dump(body, f, ensure_ascii=False, indent=2) + print(f"[完成] 请求体已保存: {args.output}") + print(f" notebook_uuid: {args.notebook_uuid}") + print(f" agent_result 字段数: {len(agent_result)}") + print(f" 合并文件数: {len(args.files)}") + + if args.submit: + if not args.auth or not args.base: + print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr) + sys.exit(1) + print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...") + result = submit(args.base, args.auth, body) + print(f" HTTP {result['status_code']}") + print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}") + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/virtual-workbench/SKILL.md b/.cursor/skills/virtual-workbench/SKILL.md new file mode 100644 index 00000000..8f7aa0fe --- /dev/null +++ b/.cursor/skills/virtual-workbench/SKILL.md @@ -0,0 +1,272 @@ +--- +name: virtual-workbench +description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations. +--- + +# Virtual Workbench API Skill + +## 设备信息 + +- **device_id**: `virtual_workbench` +- **Python 源码**: `unilabos/devices/virtual/workbench.py` +- **设备类**: `VirtualWorkbench` +- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`) +- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行) + +### 典型工作流程 + +1. `prepare_materials` — 生成 A1-A5 物料(5 个 output handle) +2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台 +3. `start_heating` — 启动加热(3 个加热台可并行) +4. `move_to_output` — 加热完成后移到输出位置 Cn + +## 前置条件(缺一不可) + +使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab ` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab " +``` + +**两项全部就绪后才可发起 API 请求。** + +## Session State + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `device_name` — `virtual_workbench` + +## 请求约定 + +所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。 + +### 2. 创建工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"<名称>","lab_uuid":"","description":"<描述>"}' +``` + +返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid` + +### 3. 创建节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","resource_template_name":"virtual_workbench","node_template_name":""}' +``` + +- `resource_template_name` 固定为 `virtual_workbench` +- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`) + +### 4. 删除节点 + +```bash +curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":[""],"workflow_uuid":""}' +``` + +### 5. 更新节点参数 + +```bash +curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","uuid":"","param":{...}}' +``` + +参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。 + +### 6. 查询节点 handles + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":["",""]}' +``` + +### 7. 批量创建边 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"edges":[{"source_node_uuid":"","target_node_uuid":"","source_handle_uuid":"","target_handle_uuid":""}]}' +``` + +### 8. 启动工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow//run" -H "$AUTH" +``` + +### 9. 运行设备单动作 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"lab_uuid":"","device_id":"virtual_workbench","action":"","action_type":"","param":{...}}' +``` + +`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。 + +> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/.json` 的 `type` 字段获取。 + +#### action_type 速查表 + +| action | action_type | +|--------|-------------| +| `auto-prepare_materials` | `UniLabJsonCommand` | +| `auto-move_to_heating_station` | `UniLabJsonCommand` | +| `auto-start_heating` | `UniLabJsonCommand` | +| `auto-move_to_output` | `UniLabJsonCommand` | +| `transfer` | `UniLabJsonCommandAsync` | +| `manual_confirm` | `UniLabJsonCommand` | + +### 10. 查询任务状态 + +```bash +curl -s -X GET "$BASE/api/v1/lab/mcp/task/" -H "$AUTH" +``` + +### 11. 运行工作流单节点 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuid":""}' +``` + +### 12. 获取资源树(物料信息) + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` + +注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。 + +### 13. 获取工作流模板详情 + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" +``` + +> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。 + +### 14. 按名称查询物料模板 + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" +``` + +返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。 + +### 15. 创建物料节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"res_template_uuid":"","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}' +``` + +### 16. 更新物料节点 + +```bash +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}' +``` + +--- + +## Placeholder Slot 填写规则 + +| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 | +| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 | + +### virtual_workbench 设备的 Slot 字段表 + +| Action | 字段 | Slot 类型 | 说明 | +| ----------------- | ---------------- | ------------ | -------------------- | +| `transfer` | `resource` | ResourceSlot | 待转移物料数组 | +| `transfer` | `target_device` | DeviceSlot | 目标设备路径 | +| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 | +| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 | +| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 | +| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 | + +> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。 + +--- + +## 渐进加载策略 + +1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览 +2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数 +3. **[actions/\.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid +- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料 +- [ ] Step 3: 读 action-index.md 确定要用的 action 名 +- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接 +- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param +- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段 +- [ ] Step 7: 更新节点参数 (PATCH #5) +- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid +- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点 +- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11) +- [ ] Step 11: 查询任务状态 (GET #10) 确认完成 +``` + +### 典型 5 物料并发加热工作流示例 + +``` +prepare_materials (count=5) + ├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output + ├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output + ├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output + ├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output + └─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output +``` + +创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。 diff --git a/.cursor/skills/virtual-workbench/action-index.md b/.cursor/skills/virtual-workbench/action-index.md new file mode 100644 index 00000000..f67d9a91 --- /dev/null +++ b/.cursor/skills/virtual-workbench/action-index.md @@ -0,0 +1,76 @@ +# Action Index — virtual_workbench + +6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 + +--- + +## 物料准备 + +### `auto-prepare_materials` + +批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json) +- **可选参数**: `count`(物料数量,默认 5) + +--- + +## 机械臂 & 加热台操作 + +### `auto-move_to_heating_station` + +将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台) + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json) +- **核心参数**: `material_number`(物料编号,integer) + +### `auto-start_heating` + +启动指定加热台的加热程序(可并行,3 个加热台同时工作) + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/start_heating.json`](actions/start_heating.json) +- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号) + +### `auto-move_to_output` + +将加热完成的物料从加热台移动到输出位置 Cn + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json) +- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号) + +--- + +## 物料转移 + +### `transfer` + +异步转移物料到目标设备(通过 ROS 资源转移) + +- **action_type**: `UniLabJsonCommandAsync` +- **Schema**: [`actions/transfer.json`](actions/transfer.json) +- **核心参数**: `resource`, `target_device`, `mount_resource` +- **占位符字段**: + - `resource` — **ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]` + - `target_device` — **DeviceSlot**,目标设备路径字符串 + - `mount_resource` — **ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]` + +--- + +## 人工确认 + +### `manual_confirm` + +创建人工确认节点,等待用户手动确认后继续(含物料转移上下文) + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) +- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids` +- **占位符字段**: + - `resource` — **ResourceSlot**,物料数组 + - `target_device` — **DeviceSlot**,目标设备路径 + - `mount_resource` — **ResourceSlot**,目标孔位数组 + - `assignee_user_ids` — `unilabos_manual_confirm` 类型 diff --git a/.cursor/skills/virtual-workbench/actions/manual_confirm.json b/.cursor/skills/virtual-workbench/actions/manual_confirm.json new file mode 100644 index 00000000..84d06f5b --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/manual_confirm.json @@ -0,0 +1,270 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "resource": "resource", + "target_device": "target_device", + "mount_resource": "mount_resource", + "timeout_seconds": "timeout_seconds", + "assignee_user_ids": "assignee_user_ids" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "type": "array" + }, + "target_device": { + "type": "string", + "description": "device reference" + }, + "mount_resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "mount_resource" + }, + "type": "array" + }, + "timeout_seconds": { + "type": "integer" + }, + "assignee_user_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "resource", + "target_device", + "mount_resource", + "timeout_seconds", + "assignee_user_ids" + ], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources", + "assignee_user_ids": "unilabos_manual_confirm" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources", + "assignee_user_ids": "unilabos_manual_confirm" + } +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json b/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json new file mode 100644 index 00000000..b5e55adc --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json @@ -0,0 +1,19 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "material_number": { + "type": "integer" + } + }, + "required": [ + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/move_to_output.json b/.cursor/skills/virtual-workbench/actions/move_to_output.json new file mode 100644 index 00000000..913e8679 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/move_to_output.json @@ -0,0 +1,24 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "station_id": "station_id", + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "station_id": { + "type": "integer" + }, + "material_number": { + "type": "integer" + } + }, + "required": [ + "station_id", + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/prepare_materials.json b/.cursor/skills/virtual-workbench/actions/prepare_materials.json new file mode 100644 index 00000000..5fbd8a9c --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/prepare_materials.json @@ -0,0 +1,20 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "count": "count" + }, + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "default": 5 + } + }, + "required": [] + }, + "goal_default": { + "count": 5 + }, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/start_heating.json b/.cursor/skills/virtual-workbench/actions/start_heating.json new file mode 100644 index 00000000..913e8679 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/start_heating.json @@ -0,0 +1,24 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "station_id": "station_id", + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "station_id": { + "type": "integer" + }, + "material_number": { + "type": "integer" + } + }, + "required": [ + "station_id", + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/transfer.json b/.cursor/skills/virtual-workbench/actions/transfer.json new file mode 100644 index 00000000..c286c68f --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/transfer.json @@ -0,0 +1,255 @@ +{ + "type": "UniLabJsonCommandAsync", + "goal": { + "resource": "resource", + "target_device": "target_device", + "mount_resource": "mount_resource" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "type": "array" + }, + "target_device": { + "type": "string", + "description": "device reference" + }, + "mount_resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "mount_resource" + }, + "type": "array" + } + }, + "required": [ + "resource", + "target_device", + "mount_resource" + ], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources" + } +} \ No newline at end of file diff --git a/docs/advanced_usage/configuration.md b/docs/advanced_usage/configuration.md index 3440044c..a885e06d 100644 --- a/docs/advanced_usage/configuration.md +++ b/docs/advanced_usage/configuration.md @@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat **获取方式:** -进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk: +进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk: ![copy_aksk.gif](image/copy_aksk.gif) @@ -69,7 +69,7 @@ class WSConfig: # HTTP配置 class HTTPConfig: - remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址 + remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址 # ROS配置 class ROSConfig: @@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap `--addr` 参数支持以下预设值,会自动转换为对应的完整 URL: -- `test` → `https://uni-lab.test.bohrium.com/api/v1` -- `uat` → `https://uni-lab.uat.bohrium.com/api/v1` +- `test` → `https://leap-lab.test.bohrium.com/api/v1` +- `uat` → `https://leap-lab.uat.bohrium.com/api/v1` - `local` → `http://127.0.0.1:48197/api/v1` - 其他值 → 直接使用作为完整 URL @@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap `ak` 和 `sk` 是必需的认证参数: -1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得 +1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得 2. **配置方式**: - **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐) - **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK` @@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式: HTTP 客户端配置用于与云端服务通信: -| 参数 | 类型 | 默认值 | 说明 | -| ------------- | ---- | -------------------------------------- | ------------ | -| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 | +| 参数 | 类型 | 默认值 | 说明 | +| ------------- | ---- | --------------------------------------- | ------------ | +| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 | **预设环境地址**: -- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认) -- 测试环境:`https://uni-lab.test.bohrium.com/api/v1` -- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1` +- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认) +- 测试环境:`https://leap-lab.test.bohrium.com/api/v1` +- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1` - 本地环境:`http://127.0.0.1:48197/api/v1` ### 4. ROSConfig - ROS 配置 @@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10" export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500" # 设置HTTP配置 -export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1" +export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1" ``` ## 配置文件使用方法 @@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100 ```python class HTTPConfig: - remote_addr = "https://uni-lab.test.bohrium.com/api/v1" + remote_addr = "https://leap-lab.test.bohrium.com/api/v1" ``` **环境变量方式:** ```bash -export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1 +export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1 ``` **命令行方式(推荐):** diff --git a/docs/developer_guide/networking_overview.md b/docs/developer_guide/networking_overview.md index 19f16312..dc742235 100644 --- a/docs/developer_guide/networking_overview.md +++ b/docs/developer_guide/networking_overview.md @@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式: ``` ┌──────────────────────────────────────────────┐ │ Cloud Platform/Self-hosted Platform │ -│ uni-lab.bohrium.com │ +│ leap-lab.bohrium.com │ │ (Resource Management, Task Scheduling, │ │ Monitoring) │ └────────────────────┬─────────────────────────┘ @@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start ```bash # 测试云端连接 -curl https://uni-lab.bohrium.com/api/v1/health +curl https://leap-lab.bohrium.com/api/v1/health # 测试WebSocket # 启动Uni-Lab后查看日志 diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md index 499ee9ee..8e4fd357 100644 --- a/docs/user_guide/best_practice.md +++ b/docs/user_guide/best_practice.md @@ -33,11 +33,11 @@ **选择合适的安装包:** -| 安装包 | 适用场景 | 包含组件 | -|--------|----------|----------| -| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | -| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | -| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | +| 安装包 | 适用场景 | 包含组件 | +| --------------- | ---------------------------- | --------------------------------------------- | +| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | +| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | +| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | **关键步骤:** @@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge ``` **选择建议:** + - **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用 - **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效 - **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt @@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')" #### 2.1 注册实验室账号 -1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) 2. 注册账号并登录 3. 创建新实验室 @@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json #### 5.2 访问 Web 界面 -启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) #### 5.3 添加设备和物料 @@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json **示例场景:** 创建一个简单的液体转移实验 1. **添加工作站(必需):** - - 在"仪器设备"中找到 `work_station` - 添加 `workstation` x1 2. **添加虚拟转移泵:** - - 在"仪器设备"中找到 `virtual_device` - 添加 `virtual_transfer_pump` x1 @@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt ``` **为什么使用这种方式?** + - `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译) - `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖 - `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像 @@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \ **详细步骤:** 1. **需求分析**: - - 明确实验流程 - 列出所需设备和物料 - 设计工作流程图 2. **环境搭建**: - - 安装 Uni-Lab-OS - 创建实验室账号 - 准备开发工具(IDE、Git) 3. **原型验证**: - - 使用虚拟设备测试流程 - 验证工作流逻辑 - 调整参数 4. **迭代开发**: - - 实现自定义设备驱动(同时撰写单点函数测试) - 编写注册表 - 单元测试 - 集成测试 5. **测试部署**: - - 连接真实硬件 - 空跑测试 - 小规模试验 @@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \ #### 14.5 社区支持 - **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues) -- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +- **官方网站**:[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) --- diff --git a/docs/user_guide/graph_files.md b/docs/user_guide/graph_files.md index d6902829..f4951dde 100644 --- a/docs/user_guide/graph_files.md +++ b/docs/user_guide/graph_files.md @@ -626,7 +626,7 @@ unilab **云端图文件管理**: -1. 登录 https://uni-lab.bohrium.com +1. 登录 https://leap-lab.bohrium.com 2. 进入"设备配置" 3. 创建或编辑配置 4. 保存到云端 diff --git a/docs/user_guide/launch.md b/docs/user_guide/launch.md index 34caa5b9..4f8df40d 100644 --- a/docs/user_guide/launch.md +++ b/docs/user_guide/launch.md @@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段: 您可以直接跟随 unilabos 的提示进行,无需查阅本节 - **工作目录设置**: - - 如果当前目录以 `unilabos_data` 结尾,则使用当前目录 - 否则使用 `当前目录/unilabos_data` 作为工作目录 - 可通过 `--working_dir` 指定自定义工作目录 @@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段: 支持多种后端环境: -- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`) -- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`) +- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`) +- `--addr uat`:UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`) - `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`) - 自定义地址:直接指定完整 URL @@ -176,7 +175,7 @@ unilab --config path/to/your/config.py 如果是首次使用,系统会: -1. 提示前往 https://uni-lab.bohrium.com 注册实验室 +1. 提示前往 https://leap-lab.bohrium.com 注册实验室 2. 引导创建配置文件 3. 设置工作目录 @@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser 如果提示 "后续运行必须拥有一个实验室",请确保: -- 已在 https://uni-lab.bohrium.com 注册实验室 +- 已在 https://leap-lab.bohrium.com 注册实验室 - 正确设置了 `--ak` 和 `--sk` 参数 - 配置文件中包含正确的认证信息 diff --git a/unilabos/app/main.py b/unilabos/app/main.py index fa7bc35d..99d733dd 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -233,7 +233,7 @@ def parse_args(): parser.add_argument( "--addr", type=str, - default="https://uni-lab.bohrium.com/api/v1", + default="https://leap-lab.bohrium.com/api/v1", help="Laboratory backend address", ) parser.add_argument( @@ -264,6 +264,12 @@ def parse_args(): default=False, help="Test mode: all actions simulate execution and return mock results without running real hardware", ) + parser.add_argument( + "--external_devices_only", + action="store_true", + default=False, + help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry", + ) parser.add_argument( "--extra_resource", action="store_true", @@ -342,11 +348,18 @@ def main(): check_mode = args_dict.get("check_mode", False) if not skip_env_check: - from unilabos.utils.environment_check import check_environment + from unilabos.utils.environment_check import check_environment, check_device_package_requirements if not check_environment(auto_install=True): print_status("环境检查失败,程序退出", "error") os._exit(1) + + # 第一次设备包依赖检查:build_registry 之前,确保 import map 可用 + devices_dirs_for_req = args_dict.get("devices", None) + if devices_dirs_for_req: + if not check_device_package_requirements(devices_dirs_for_req): + print_status("设备包依赖检查失败,程序退出", "error") + os._exit(1) else: print_status("跳过环境依赖检查", "warning") @@ -425,10 +438,10 @@ def main(): if args.addr != parser.get_default("addr"): if args.addr == "test": print_status("使用测试环境地址", "info") - HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1" + HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1" elif args.addr == "uat": print_status("使用uat环境地址", "info") - HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1" + HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1" elif args.addr == "local": print_status("使用本地环境地址", "info") HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1" @@ -477,19 +490,7 @@ def main(): BasicConfig.vis_2d_enable = args_dict["2d_vis"] BasicConfig.check_mode = check_mode - from unilabos.resources.graphio import ( - read_node_link_json, - read_graphml, - dict_from_graph, - ) - from unilabos.app.communication import get_communication_client from unilabos.registry.registry import build_registry - from unilabos.app.backend import start_backend - from unilabos.app.web import http_client - from unilabos.app.web import start_server - from unilabos.app.register import register_devices_and_resources - from unilabos.resources.graphio import modify_to_backend_format - from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict # 显示启动横幅 print_unilab_banner(args_dict) @@ -498,12 +499,14 @@ def main(): # check_mode 和 upload_registry 都会执行实际 import 验证 devices_dirs = args_dict.get("devices", None) complete_registry = args_dict.get("complete_registry", False) or check_mode + external_only = args_dict.get("external_devices_only", False) lab_registry = build_registry( registry_paths=args_dict["registry_path"], devices_dirs=devices_dirs, upload_registry=BasicConfig.upload_registry, check_mode=check_mode, complete_registry=complete_registry, + external_only=external_only, ) # Check mode: 注册表验证完成后直接退出 @@ -513,6 +516,20 @@ def main(): print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info") os._exit(0) + # 以下导入依赖 ROS2 环境,check_mode 已退出不需要 + from unilabos.resources.graphio import ( + read_node_link_json, + read_graphml, + dict_from_graph, + modify_to_backend_format, + ) + from unilabos.app.communication import get_communication_client + from unilabos.app.backend import start_backend + from unilabos.app.web import http_client + from unilabos.app.web import start_server + from unilabos.app.register import register_devices_and_resources + from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict + # Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data if BasicConfig.upload_registry: if BasicConfig.ak and BasicConfig.sk: @@ -536,7 +553,7 @@ def main(): os._exit(0) if not BasicConfig.ak or not BasicConfig.sk: - print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning") + print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning") os._exit(1) graph: nx.Graph resource_tree_set: ResourceTreeSet @@ -610,6 +627,10 @@ def main(): resource_tree_set.merge_remote_resources(remote_tree_set) print_status("远端物料同步完成", "info") + # 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements + # TODO: 当 community device package 功能上线后,在这里调用 + # install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx") + # 使用 ResourceTreeSet 代替 list args_dict["resources_config"] = resource_tree_set args_dict["devices_config"] = resource_tree_set diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index b1cc67eb..527b813e 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -36,6 +36,9 @@ class HTTPClient: auth_secret = BasicConfig.auth_secret() self.auth = auth_secret info(f"正在使用ak sk作为授权信息:[{auth_secret}]") + # 复用 TCP/TLS 连接,避免每次请求重新握手 + self._session = requests.Session() + self._session.headers.update({"Authorization": f"Lab {self.auth}"}) info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}") def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response: @@ -48,7 +51,7 @@ class HTTPClient: Returns: Response: API响应对象 """ - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material/edge", json={ "edges": resources, @@ -75,25 +78,28 @@ class HTTPClient: Returns: Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid} """ - with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f: - payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid} - f.write(json.dumps(payload, indent=4)) - # 从序列化数据中提取所有节点的UUID(保存旧UUID) + # dump() 只调用一次,复用给文件保存和 HTTP 请求 + nodes_info = [x for xs in resources.dump() for x in xs] old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} + payload = {"nodes": nodes_info, "mount_uuid": mount_uuid} + body_bytes = _fast_dumps(payload) + with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f: + f.write(_fast_dumps_pretty(payload)) + http_headers = {"Content-Type": "application/json"} if not self.initialized or first_add: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material", - json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, - headers={"Authorization": f"Lab {self.auth}"}, + data=body_bytes, + headers=http_headers, timeout=60, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/edge/material", - json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, - headers={"Authorization": f"Lab {self.auth}"}, + data=body_bytes, + headers=http_headers, timeout=10, ) @@ -111,6 +117,7 @@ class HTTPClient: uuid_mapping[i["uuid"]] = i["cloud_uuid"] else: logger.error(f"添加物料失败: {response.text}") + logger.trace(f"添加物料失败: {nodes_info}") for u, n in old_uuids.items(): if u in uuid_mapping: n.res_content.uuid = uuid_mapping[u] @@ -131,7 +138,7 @@ class HTTPClient: """ with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f: f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4)) - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material/query", json={"uuids": uuid_list, "with_children": with_children}, headers={"Authorization": f"Lab {self.auth}"}, @@ -145,6 +152,7 @@ class HTTPClient: logger.error(f"查询物料失败: {response.text}") else: data = res["data"]["nodes"] + logger.trace(f"resource_tree_get查询到物料: {data}") return data else: logger.error(f"查询物料失败: {response.text}") @@ -162,14 +170,14 @@ class HTTPClient: if not self.initialized: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, timeout=100, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, @@ -196,7 +204,7 @@ class HTTPClient: """ with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f: f.write(json.dumps({"id": id, "with_children": with_children}, indent=4)) - response = requests.get( + response = self._session.get( f"{self.remote_addr}/lab/material", params={"id": id, "with_children": with_children}, headers={"Authorization": f"Lab {self.auth}"}, @@ -237,14 +245,14 @@ class HTTPClient: if not self.initialized: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, timeout=100, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, @@ -274,7 +282,7 @@ class HTTPClient: with open(file_path, "rb") as file: files = {"files": file} logger.info(f"上传文件: {file_path} 到 {scene}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/api/account/file_upload/{scene}", files=files, headers={"Authorization": f"Lab {self.auth}"}, @@ -314,7 +322,7 @@ class HTTPClient: "Content-Type": "application/json", "Content-Encoding": "gzip", } - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/resource", data=compressed_body, headers=headers, @@ -348,7 +356,7 @@ class HTTPClient: Returns: Response: API响应对象 """ - response = requests.get( + response = self._session.get( f"{self.remote_addr}/edge/material/download", headers={"Authorization": f"Lab {self.auth}"}, timeout=(3, 30), @@ -409,7 +417,7 @@ class HTTPClient: with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f: f.write(json.dumps(payload, indent=4, ensure_ascii=False)) - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/workflow/owner/import", json=payload, headers={"Authorization": f"Lab {self.auth}"}, diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index cbbb58ef..4823a232 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -754,6 +754,32 @@ class MessageProcessor: req = JobAddReq(**data) job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) + + # 服务端对always_free动作可能跳过query_action_state直接发job_start, + # 此时job尚未注册,需要自动补注册 + existing_job = self.device_manager.get_job_info(req.job_id) + if not existing_job: + action_name = req.action + device_action_key = f"/devices/{req.device_id}/{action_name}" + action_always_free = self._check_action_always_free(req.device_id, action_name) + + if action_always_free: + job_info = JobInfo( + job_id=req.job_id, + task_id=req.task_id, + device_id=req.device_id, + action_name=action_name, + device_action_key=device_action_key, + status=JobStatus.QUEUE, + start_time=time.time(), + always_free=True, + ) + self.device_manager.add_queue_request(job_info) + logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") + else: + logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") + return + success = self.device_manager.start_job(req.job_id) if not success: logger.error(f"[MessageProcessor] Failed to start job {job_log}") @@ -1087,7 +1113,7 @@ class MessageProcessor: "task_id": task_id, "job_id": job_id, "free": free, - "need_more": need_more, + "need_more": need_more + 1, }, } @@ -1227,7 +1253,7 @@ class QueueProcessor: "task_id": job_info.task_id, "job_id": job_info.job_id, "free": False, - "need_more": 10, + "need_more": 10 + 1, }, } self.message_processor.send_message(message) @@ -1243,7 +1269,13 @@ class QueueProcessor: if not queued_jobs: return - logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs") + queue_summary = {} + for j in queued_jobs: + key = f"{j.device_id}/{j.action_name}" + queue_summary[key] = queue_summary.get(key, 0) + 1 + logger.debug( + f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}" + ) for job_info in queued_jobs: # 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY, @@ -1260,7 +1292,7 @@ class QueueProcessor: "task_id": job_info.task_id, "job_id": job_info.job_id, "free": False, - "need_more": 10, + "need_more": 10 + 1, }, } success = self.message_processor.send_message(message) @@ -1343,6 +1375,10 @@ class WebSocketClient(BaseCommunicationClient): self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager) self.queue_processor = QueueProcessor(self.device_manager, self.message_processor) + # running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)} + self._job_running_last_sent: Dict[str, tuple] = {} + self._job_running_debounce_interval: float = 10.0 # 秒 + # 设置相互引用 self.message_processor.set_queue_processor(self.queue_processor) self.message_processor.set_websocket_client(self) @@ -1442,22 +1478,32 @@ class WebSocketClient(BaseCommunicationClient): logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}") return + job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) + # 拦截最终结果状态,与原版本逻辑一致 if status in ["success", "failed"]: + self._job_running_last_sent.pop(item.job_id, None) + host_node = HostNode.get_instance(0) if host_node: - # 从HostNode的device_action_status中移除job_id try: host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None) except (KeyError, AttributeError): logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status") - # logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}") - - # 通知队列处理器job完成(包括timeout的job) self.queue_processor.handle_job_completed(item.job_id, status) - # 发送job状态消息 + # running状态按job_id做debounce,内容变化时仍然上报 + if status == "running": + now = time.time() + cached = self._job_running_last_sent.get(item.job_id) + if cached is not None: + last_ts, last_data = cached + if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data: + logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}") + return + self._job_running_last_sent[item.job_id] = (now, feedback_data) + message = { "action": "job_status", "data": { @@ -1473,7 +1519,6 @@ class WebSocketClient(BaseCommunicationClient): } self.message_processor.send_message(message) - job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}") def send_ping(self, ping_id: str, timestamp: float) -> None: diff --git a/unilabos/config/config.py b/unilabos/config/config.py index b80d3b60..d8d000e2 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -46,7 +46,7 @@ class WSConfig: # HTTP配置 class HTTPConfig: - remote_addr = "https://uni-lab.bohrium.com/api/v1" + remote_addr = "https://leap-lab.bohrium.com/api/v1" # ROS配置 diff --git a/unilabos/devices/virtual/virtual_sample_demo.py b/unilabos/devices/virtual/virtual_sample_demo.py new file mode 100644 index 00000000..0b785dc3 --- /dev/null +++ b/unilabos/devices/virtual/virtual_sample_demo.py @@ -0,0 +1,88 @@ +"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo""" + +import asyncio +import logging +import random +import time +from typing import Any, Dict, List, Optional + + +class VirtualSampleDemo: + """虚拟样品追踪演示设备,提供两种典型返回模式: + - measure_samples: 等长输入输出 (前端按 index 自动对齐) + - split_and_measure: 输出比输入长,附带 samples 列标注归属 + """ + + def __init__(self, device_id: Optional[str] = None, config: Optional[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_sample_demo" + self.config = config or {} + self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}") + self.data: Dict[str, Any] = {"status": "Idle"} + + # ------------------------------------------------------------------ + # Action 1: 等长输入输出,无 samples 列 + # ------------------------------------------------------------------ + async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]: + """模拟光度测量。absorbance = concentration * 0.05 + noise + + 入参和出参 list 长度相等,前端按 index 自动对齐。 + """ + self.logger.info(f"measure_samples: concentrations={concentrations}") + absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations] + return {"concentrations": concentrations, "absorbance": absorbance} + + # ------------------------------------------------------------------ + # Action 2: 输出比输入长,带 samples 列 + # ------------------------------------------------------------------ + async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]: + """将每个样品均分为 split_count 份后逐份测量。 + + 返回的 list 长度 = len(volumes) * split_count, + 附带 samples 列标注每行属于第几个输入样品 (0-based index)。 + """ + self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}") + out_volumes: List[float] = [] + readings: List[float] = [] + samples: List[int] = [] + + for idx, vol in enumerate(volumes): + split_vol = round(vol / split_count, 2) + for _ in range(split_count): + out_volumes.append(split_vol) + readings.append(round(random.uniform(0.1, 1.0), 4)) + samples.append(idx) + + return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples} + + # ------------------------------------------------------------------ + # Action 3: 入参和出参都带 samples 列(不等长) + # ------------------------------------------------------------------ + async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]: + """对 split_and_measure 的输出做二次分析。 + + 入参 readings/samples 长度相同但 > 原始样品数, + 出参同样带 samples 列,长度与入参一致。 + """ + self.logger.info(f"analyze_readings: readings={readings}, samples={samples}") + scores: List[float] = [] + passed: List[bool] = [] + threshold = 0.4 + + for r in readings: + score = round(r * 100 + random.gauss(0, 2), 2) + scores.append(score) + passed.append(r >= threshold) + + return {"scores": scores, "passed": passed, "unilabos_samples": samples} + + # ------------------------------------------------------------------ + # 状态属性 + # ------------------------------------------------------------------ + @property + def status(self) -> str: + return self.data.get("status", "Idle") diff --git a/unilabos/devices/virtual/workbench.py b/unilabos/devices/virtual/workbench.py index d67db398..c70c8f66 100644 --- a/unilabos/devices/virtual/workbench.py +++ b/unilabos/devices/virtual/workbench.py @@ -22,10 +22,11 @@ from threading import Lock, RLock from typing_extensions import TypedDict from unilabos.registry.decorators import ( - device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action + device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType ) -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode -from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode +from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet # ============ TypedDict 返回类型定义 ============ @@ -290,6 +291,126 @@ class VirtualWorkbench: self._update_data_status(f"机械臂已释放 (完成: {task})") self.logger.info(f"机械臂已释放 (完成: {task})") + @action( + always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={ + "assignee_user_ids": "unilabos_manual_confirm" + }, goal_default={ + "timeout_seconds": 3600, + "assignee_user_ids": [] + }, feedback_interval=300, + handles=[ + ActionInputHandle(key="target_device", data_type="device_id", + label="目标设备", data_key="target_device", data_source=DataSource.HANDLE), + ActionInputHandle(key="resource", data_type="resource", + label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), + ActionInputHandle(key="mount_resource", data_type="resource", + label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), + + ActionInputHandle(key="collector_mass", data_type="collector_mass", + label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE), + ActionInputHandle(key="active_material", data_type="active_material", + label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE), + ActionInputHandle(key="capacity", data_type="capacity", + label="克容量", data_key="capacity", data_source=DataSource.HANDLE), + ActionInputHandle(key="battery_system", data_type="battery_system", + label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE), + # transfer使用 + ActionOutputHandle(key="target_device", data_type="device_id", + label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="resource", data_type="resource", + label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="mount_resource", data_type="resource", + label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR), + # test使用 + ActionOutputHandle(key="collector_mass", data_type="collector_mass", + label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="active_material", data_type="active_material", + label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="capacity", data_type="capacity", + label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="battery_system", data_type="battery_system", + label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR), + ] + ) + def manual_confirm( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: List[str], + timeout_seconds: int, + assignee_user_ids: list[str], + **kwargs + ) -> dict: + """ + timeout_seconds: 超时时间(秒),默认3600秒 + collector_mass: 极流体质量 + active_material: 活性物质含量 + capacity: 克容量(mAh/g) + battery_system: 电池体系 + 修改的结果无效,是只读的 + """ + resource = ResourceTreeSet.from_plr_resources(resource).dump() + mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump() + kwargs.update(locals()) + kwargs.pop("kwargs") + kwargs.pop("self") + return kwargs + + @action( + description="转移物料", + handles=[ + ActionInputHandle(key="target_device", data_type="device_id", + label="目标设备", data_key="target_device", data_source=DataSource.HANDLE), + ActionInputHandle(key="resource", data_type="resource", + label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), + ActionInputHandle(key="mount_resource", data_type="resource", + label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), + ] + ) + async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]): + future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, + **{ + "plr_resources": resource, + "target_device_id": target_device, + "target_resources": mount_resource, + "sites": [None] * len(mount_resource), + }) + result = await future + return result + + + @action( + description="扣电测试启动", + handles=[ + ActionInputHandle(key="resource", data_type="resource", + label="待转移资源", data_key="resource", data_source=DataSource.HANDLE), + ActionInputHandle(key="mount_resource", data_type="resource", + label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE), + + ActionInputHandle(key="collector_mass", data_type="collector_mass", + label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE), + ActionInputHandle(key="active_material", data_type="active_material", + label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE), + ActionInputHandle(key="capacity", data_type="capacity", + label="克容量", data_key="capacity", data_source=DataSource.HANDLE), + ActionInputHandle(key="battery_system", data_type="battery_system", + label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE), + ] + ) + async def test( + self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str] + ): + print(resource) + print(mount_resource) + print(collector_mass) + print(active_material) + print(capacity) + print(battery_system) + @action( auto_prefix=True, description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用", diff --git a/unilabos/registry/ast_registry_scanner.py b/unilabos/registry/ast_registry_scanner.py index 86c3602e..62cd2dbe 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -139,6 +139,7 @@ def scan_directory( executor: ThreadPoolExecutor = None, exclude_files: Optional[set] = None, cache: Optional[Dict[str, Any]] = None, + include_files: Optional[List[Union[str, Path]]] = None, ) -> Dict[str, Any]: """ Recursively scan .py files under *root_dir* for @device and @resource @@ -164,6 +165,7 @@ def scan_directory( exclude_files: 要排除的文件名集合 (如 {"lab_resources.py"}) cache: Mutable cache dict (``load_scan_cache()`` result). Hits are read from here; misses are written back so the caller can persist later. + include_files: 指定扫描的文件列表,提供时跳过目录递归收集,直接扫描这些文件。 """ if executor is None: raise ValueError("executor is required and must not be None") @@ -175,7 +177,10 @@ def scan_directory( python_path = Path(python_path).resolve() # --- Collect files (depth/count limited) --- - py_files = _collect_py_files(root_dir, max_depth=max_depth, max_files=max_files, exclude_files=exclude_files) + if include_files is not None: + py_files = [Path(f).resolve() for f in include_files if Path(f).resolve().exists()] + else: + py_files = _collect_py_files(root_dir, max_depth=max_depth, max_files=max_files, exclude_files=exclude_files) cache_files: Dict[str, Any] = cache.get("files", {}) if cache else {} @@ -674,14 +679,17 @@ def _resolve_name(name: str, import_map: Dict[str, str]) -> str: return name +_DECORATOR_ENUM_CLASSES = frozenset({"Side", "DataSource", "NodeType"}) + + def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str: """ Resolve an attribute access like Side.NORTH or DataSource.HANDLE. - Returns a string like "NORTH" for enum values, or - "module.path:Class.attr" for imported references. + 对于来自 ``unilabos.registry.decorators`` 的枚举类 (Side / DataSource / NodeType), + 直接返回枚举成员名 (如 ``"NORTH"`` / ``"HANDLE"`` / ``"MANUAL_CONFIRM"``), + 省去消费端二次 rsplit 解析。其它 import 仍返回完整模块路径。 """ - # Get the full dotted path parts = [] current = node while isinstance(current, ast.Attribute): @@ -691,21 +699,20 @@ def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str: parts.append(current.id) parts.reverse() - # parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"] + # parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"] or ["NodeType", "MANUAL_CONFIRM"] if len(parts) >= 2: base = parts[0] attr = ".".join(parts[1:]) - # If the base is an imported name, resolve it + if base in _DECORATOR_ENUM_CLASSES: + source = import_map.get(base, "") + if not source or _REGISTRY_DECORATOR_MODULE in source: + return parts[-1] + if base in import_map: return f"{import_map[base]}.{attr}" - # For known enum-like patterns, return just the value - # e.g. Side.NORTH -> "NORTH" - if base in ("Side", "DataSource"): - return parts[-1] - return ".".join(parts) @@ -818,6 +825,7 @@ def _extract_class_body( action_args.setdefault("placeholder_keys", {}) action_args.setdefault("always_free", False) action_args.setdefault("is_protocol", False) + action_args.setdefault("feedback_interval", 1.0) action_args.setdefault("description", "") action_args.setdefault("auto_prefix", False) action_args.setdefault("parent", False) diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index e8c65ac8..1dffe169 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -8,7 +8,7 @@ Usage: device, action, resource, InputHandle, OutputHandle, ActionInputHandle, ActionOutputHandle, - HardwareInterface, Side, DataSource, + HardwareInterface, Side, DataSource, NodeType, ) @device( @@ -73,6 +73,13 @@ class DataSource(str, Enum): EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle) +class NodeType(str, Enum): + """动作的节点类型(用于区分 ILab 节点和人工确认节点等)""" + + ILAB = "ILab" + MANUAL_CONFIRM = "manual_confirm" + + # --------------------------------------------------------------------------- # Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type) # --------------------------------------------------------------------------- @@ -335,6 +342,8 @@ def action( description: str = "", auto_prefix: bool = False, parent: bool = False, + node_type: Optional["NodeType"] = None, + feedback_interval: Optional[float] = None, ): """ 动作方法装饰器 @@ -365,12 +374,21 @@ def action( description: 动作描述 auto_prefix: 若为 True,动作名使用 auto-{method_name} 形式(与无 @action 时一致) parent: 若为 True,当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数 + node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。 + 不填写时不写入注册表。 """ def decorator(func: F) -> F: - @wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) + import asyncio as _asyncio + + if _asyncio.iscoroutinefunction(func): + @wraps(func) + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + else: + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) # action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand) resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type @@ -389,6 +407,10 @@ def action( "auto_prefix": auto_prefix, "parent": parent, } + if feedback_interval is not None: + meta["feedback_interval"] = feedback_interval + if node_type is not None: + meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type) wrapper._action_registry_meta = meta # type: ignore[attr-defined] # 设置 _is_always_free 保持与旧 @always_free 装饰器兼容 @@ -515,6 +537,38 @@ def clear_registry(): _registered_resources.clear() +# --------------------------------------------------------------------------- +# 枚举值归一化 +# --------------------------------------------------------------------------- + + +def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]: + """将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。 + + 适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。 + + 处理以下格式: + - "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm" + - "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm" + - "HANDLE" → DataSource["HANDLE"].value = "handle" + - "NORTH" → Side["NORTH"].value = "NORTH" + - 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找 + """ + if not raw: + return None + raw_str = str(raw) + if "." in raw_str: + raw_str = raw_str.rsplit(".", 1)[-1] + try: + return enum_cls[raw_str].value + except KeyError: + pass + try: + return enum_cls(raw_str).value + except ValueError: + return raw_str + + # --------------------------------------------------------------------------- # topic_config / not_action / always_free 装饰器 # --------------------------------------------------------------------------- diff --git a/unilabos/registry/devices/hotel.yaml b/unilabos/registry/devices/hotel.yaml index fdcc89dd..15d96286 100644 --- a/unilabos/registry/devices/hotel.yaml +++ b/unilabos/registry/devices/hotel.yaml @@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel: type: object model: mesh: thermo_orbitor_rs2_hotel - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index ff357ad4..d4874677 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -329,7 +329,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual: type: object model: mesh: arm_slider - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro type: device version: 1.0.0 robotic_arm.UR: diff --git a/unilabos/registry/devices/virtual_device.yaml b/unilabos/registry/devices/virtual_device.yaml index 67560f2f..0fce3824 100644 --- a/unilabos/registry/devices/virtual_device.yaml +++ b/unilabos/registry/devices/virtual_device.yaml @@ -2804,6 +2804,203 @@ virtual_rotavap: - vacuum_pressure type: object version: 1.0.0 +virtual_sample_demo: + category: + - virtual_device + class: + action_value_mappings: + analyze_readings: + feedback: {} + goal: + readings: readings + samples: samples + goal_default: + readings: null + samples: null + handles: + input: + - data_key: readings + data_source: handle + data_type: sample_list + handler_key: readings_in + label: 测量读数 + - data_key: samples + data_source: handle + data_type: sample_index + handler_key: samples_in + label: 样品索引 + output: + - data_key: scores + data_source: executor + data_type: sample_list + handler_key: scores_out + label: 分析得分 + - data_key: passed + data_source: executor + data_type: sample_list + handler_key: passed_out + label: 是否通过 + - data_key: samples + data_source: executor + data_type: sample_index + handler_key: samples_result_out + label: 样品索引 + placeholder_keys: {} + result: {} + schema: + description: 对 split_and_measure 输出做二次分析,入参和出参都带 samples 列 + properties: + feedback: + title: AnalyzeReadings_Feedback + goal: + properties: + readings: + description: 测量读数(来自 split_and_measure) + items: + type: number + type: array + samples: + description: 每行归属的输入样品 index (0-based) + items: + type: integer + type: array + required: + - readings + - samples + title: AnalyzeReadings_Goal + type: object + result: + title: AnalyzeReadings_Result + type: object + required: + - goal + title: analyze_readings参数 + type: object + type: UniLabJsonCommandAsync + measure_samples: + feedback: {} + goal: + concentrations: concentrations + goal_default: + concentrations: null + handles: + output: + - data_key: concentrations + data_source: executor + data_type: sample_list + handler_key: concentrations_out + label: 浓度列表 + - data_key: absorbance + data_source: executor + data_type: sample_list + handler_key: absorbance_out + label: 吸光度列表 + placeholder_keys: {} + result: {} + schema: + description: 模拟光度测量,入参出参等长 + properties: + feedback: + title: MeasureSamples_Feedback + goal: + properties: + concentrations: + description: 样品浓度列表 + items: + type: number + type: array + required: + - concentrations + title: MeasureSamples_Goal + type: object + result: + title: MeasureSamples_Result + type: object + required: + - goal + title: measure_samples参数 + type: object + type: UniLabJsonCommandAsync + split_and_measure: + feedback: {} + goal: + split_count: split_count + volumes: volumes + goal_default: + split_count: 3 + volumes: null + handles: + output: + - data_key: readings + data_source: executor + data_type: sample_list + handler_key: readings_out + label: 测量读数 + - data_key: samples + data_source: executor + data_type: sample_index + handler_key: samples_out + label: 样品索引 + - data_key: volumes + data_source: executor + data_type: sample_list + handler_key: volumes_out + label: 均分体积 + placeholder_keys: {} + result: {} + schema: + description: 均分样品后逐份测量,输出带 samples 列标注归属 + properties: + feedback: + title: SplitAndMeasure_Feedback + goal: + properties: + split_count: + default: 3 + description: 每个样品均分的份数 + type: integer + volumes: + description: 样品体积列表 + items: + type: number + type: array + required: + - volumes + title: SplitAndMeasure_Goal + type: object + result: + title: SplitAndMeasure_Result + type: object + required: + - goal + title: split_and_measure参数 + type: object + type: UniLabJsonCommandAsync + module: unilabos.devices.virtual.virtual_sample_demo:VirtualSampleDemo + status_types: + status: str + type: python + config_info: [] + description: Virtual sample tracking demo device + handles: [] + icon: '' + init_param_schema: + config: + properties: + config: + type: object + device_id: + type: string + required: [] + type: object + data: + properties: + status: + type: string + required: + - status + type: object + version: 1.0.0 virtual_separator: category: - virtual_device diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 8841764c..aa3db9b2 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -33,6 +33,8 @@ from unilabos.registry.decorators import ( is_not_action, is_always_free, get_topic_config, + NodeType, + normalize_enum_value, ) from unilabos.registry.utils import ( ROSMsgNotFound, @@ -112,7 +114,7 @@ class Registry: # 统一入口 # ------------------------------------------------------------------ - def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False): + def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False, external_only=False): """统一构建注册表入口。""" if self._setup_called: logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用") @@ -123,24 +125,27 @@ class Registry: ) # 1. AST 静态扫描 (快速, 无需 import) - self._run_ast_scan(devices_dirs, upload_registry=upload_registry) + self._run_ast_scan(devices_dirs, upload_registry=upload_registry, external_only=external_only) # 2. Host node 内置设备 self._setup_host_node() - # 3. YAML 注册表加载 (兼容旧格式) - self.registry_paths = [Path(path).absolute() for path in self.registry_paths] - for i, path in enumerate(self.registry_paths): - sys_path = path.parent - logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}") - sys.path.append(str(sys_path)) - self.load_device_types(path, complete_registry=complete_registry) - if BasicConfig.enable_resource_load: - self.load_resource_types(path, upload_registry, complete_registry=complete_registry) - else: - logger.warning( - "[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载" - ) + # 3. YAML 注册表加载 (兼容旧格式) — external_only 模式下跳过 + if external_only: + logger.info("[UniLab Registry] external_only 模式: 跳过 YAML 注册表加载") + else: + self.registry_paths = [Path(path).absolute() for path in self.registry_paths] + for i, path in enumerate(self.registry_paths): + sys_path = path.parent + logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}") + sys.path.append(str(sys_path)) + self.load_device_types(path, complete_registry=complete_registry) + if BasicConfig.enable_resource_load: + self.load_resource_types(path, upload_registry, complete_registry=complete_registry) + else: + logger.warning( + "[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载" + ) self._startup_executor.shutdown(wait=True) self._startup_executor = None self._setup_called = True @@ -156,9 +161,10 @@ class Registry: ast_entry = self.device_type_registry.get("host_node", {}) ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {}) - # 取出 AST 生成的 auto-method entries, 补充特定覆写 + # 取出 AST 生成的 action entries, 补充特定覆写 test_latency_action = ast_actions.get("auto-test_latency", {}) test_resource_action = ast_actions.get("auto-test_resource", {}) + manual_confirm_action = ast_actions.get("manual_confirm", {}) test_resource_action["handles"] = { "input": [ { @@ -231,9 +237,12 @@ class Registry: "parent": "unilabos_nodes", "class_name": "unilabos_class", }, + "always_free": True, + "feedback_interval": 300.0, }, "test_latency": test_latency_action, "auto-test_resource": test_resource_action, + "manual_confirm": manual_confirm_action, }, "init_params": {}, }, @@ -253,7 +262,7 @@ class Registry: # AST 静态扫描 # ------------------------------------------------------------------ - def _run_ast_scan(self, devices_dirs=None, upload_registry=False): + def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only=False): """ 执行 AST 静态扫描,从 Python 代码中提取 @device / @resource 装饰器元数据。 无需 import 任何驱动模块,速度极快。 @@ -298,16 +307,30 @@ class Registry: extra_dirs.append(d_path) # 主扫描 - exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None - scan_result = scan_directory( - scan_root, python_path=python_path, executor=self._startup_executor, - exclude_files=exclude_files, cache=ast_cache, - ) - if exclude_files: - logger.info( - f"[UniLab Registry] 排除扫描文件: {exclude_files} " - f"(可通过 --extra_resource 启用加载)" + if external_only: + core_files = [ + pkg_root / "ros" / "nodes" / "presets" / "host_node.py", + pkg_root / "resources" / "container.py", + ] + scan_result = scan_directory( + scan_root, python_path=python_path, executor=self._startup_executor, + cache=ast_cache, include_files=core_files, ) + logger.info( + f"[UniLab Registry] external_only 模式: 仅扫描核心文件 " + f"({', '.join(f.name for f in core_files)})" + ) + else: + exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None + scan_result = scan_directory( + scan_root, python_path=python_path, executor=self._startup_executor, + exclude_files=exclude_files, cache=ast_cache, + ) + if exclude_files: + logger.info( + f"[UniLab Registry] 排除扫描文件: {exclude_files} " + f"(可通过 --extra_resource 启用加载)" + ) # 合并缓存统计 total_stats = scan_result.pop("_cache_stats", {"hits": 0, "misses": 0, "total": 0}) @@ -807,8 +830,9 @@ class Registry: raw_handles = (action_args or {}).get("handles") handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) - # placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测 - pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params) + # placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充 + pk = detect_placeholder_keys(params) + pk.update((action_args or {}).get("placeholder_keys") or {}) # 从方法返回值类型生成 result schema result_schema = None @@ -830,6 +854,11 @@ class Registry: } if (action_args or {}).get("always_free") or method_info.get("always_free"): entry["always_free"] = True + _fb_iv = (action_args or {}).get("feedback_interval", method_info.get("feedback_interval", 1.0)) + entry["feedback_interval"] = _fb_iv + nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType) + if nt: + entry["node_type"] = nt return action_name, entry # 1) auto- actions @@ -950,10 +979,15 @@ class Registry: "schema": schema, "goal_default": goal_default, "handles": handles, - "placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params), + "placeholder_keys": {**detect_placeholder_keys(method_params), **(action_args.get("placeholder_keys") or {})}, } if action_args.get("always_free") or method_info.get("always_free"): action_entry["always_free"] = True + _fb_iv = action_args.get("feedback_interval", method_info.get("feedback_interval", 1.0)) + action_entry["feedback_interval"] = _fb_iv + nt = normalize_enum_value(action_args.get("node_type"), NodeType) + if nt: + action_entry["node_type"] = nt action_value_mappings[action_name] = action_entry action_value_mappings = dict(sorted(action_value_mappings.items())) @@ -1136,7 +1170,7 @@ class Registry: return Path(BasicConfig.working_dir) / "registry_cache.pkl" return None - _CACHE_VERSION = 3 + _CACHE_VERSION = 4 def _load_config_cache(self) -> dict: import pickle @@ -1534,9 +1568,9 @@ class Registry: del resource_info["config_info"] if "file_path" in resource_info: del resource_info["file_path"] - complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items()))) resource_info["registry_type"] = "resource" resource_info["file_path"] = str(file.absolute()).replace("\\", "/") + complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items()))) for rid in skip_ids: data.pop(rid, None) @@ -1861,6 +1895,9 @@ class Registry: } if v.get("always_free"): entry["always_free"] = True + old_node_type = old_cfg.get("node_type") + if old_node_type in [NodeType.ILAB.value, NodeType.MANUAL_CONFIRM.value]: + entry["node_type"] = old_node_type device_config["class"]["action_value_mappings"][action_key] = entry device_config["init_param_schema"] = {} @@ -2175,7 +2212,7 @@ class Registry: lab_registry = Registry() -def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False): +def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False): """ 构建或获取Registry单例实例 """ @@ -2189,7 +2226,7 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False if path not in current_paths: lab_registry.registry_paths.append(path) - lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry) + lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) lab_registry.resolve_all_types() diff --git a/unilabos/registry/resources/common/resource_container.yaml b/unilabos/registry/resources/common/resource_container.yaml index 3f0aa9d2..751f1aa5 100644 --- a/unilabos/registry/resources/common/resource_container.yaml +++ b/unilabos/registry/resources/common/resource_container.yaml @@ -17,7 +17,7 @@ hplc_plate: - 0 - 0 - 3.1416 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro type: resource version: 1.0.0 plate_96: @@ -39,7 +39,7 @@ plate_96: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro type: resource version: 1.0.0 plate_96_high: @@ -61,7 +61,7 @@ plate_96_high: - 1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro type: resource version: 1.0.0 tiprack_96_high: @@ -76,7 +76,7 @@ tiprack_96_high: init_param_schema: {} model: children_mesh: generic_labware_tube_10_75/meshes/0_base.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_tf: - 0.0018 - 0.0018 @@ -92,7 +92,7 @@ tiprack_96_high: - 1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro type: resource version: 1.0.0 tiprack_box: @@ -107,7 +107,7 @@ tiprack_box: init_param_schema: {} model: children_mesh: tip/meshes/tip.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro children_mesh_tf: - 0.0045 - 0.0045 @@ -123,6 +123,6 @@ tiprack_box: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/laiyu/container.yaml b/unilabos/registry/resources/laiyu/container.yaml index 586e3cfe..400bc931 100644 --- a/unilabos/registry/resources/laiyu/container.yaml +++ b/unilabos/registry/resources/laiyu/container.yaml @@ -11,7 +11,7 @@ bottle_container: init_param_schema: {} model: children_mesh: bottle/meshes/bottle.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro children_mesh_tf: - 0.04 - 0.04 @@ -27,7 +27,7 @@ bottle_container: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro type: resource version: 1.0.0 tube_container: @@ -43,7 +43,7 @@ tube_container: init_param_schema: {} model: children_mesh: tube/meshes/tube.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro children_mesh_tf: - 0.017 - 0.017 @@ -59,6 +59,6 @@ tube_container: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/laiyu/deck.yaml b/unilabos/registry/resources/laiyu/deck.yaml index 85da0ca7..89973dde 100644 --- a/unilabos/registry/resources/laiyu/deck.yaml +++ b/unilabos/registry/resources/laiyu/deck.yaml @@ -10,6 +10,6 @@ TransformXYZDeck: init_param_schema: {} model: mesh: liquid_transform_xyz - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/deck.yaml b/unilabos/registry/resources/opentrons/deck.yaml index 10e91cef..0e35e7b1 100644 --- a/unilabos/registry/resources/opentrons/deck.yaml +++ b/unilabos/registry/resources/opentrons/deck.yaml @@ -10,7 +10,7 @@ OTDeck: init_param_schema: {} model: mesh: opentrons_liquid_handler - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro type: device version: 1.0.0 hplc_station: @@ -25,6 +25,6 @@ hplc_station: init_param_schema: {} model: mesh: hplc_station - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/plates.yaml b/unilabos/registry/resources/opentrons/plates.yaml index 20a71995..883bf147 100644 --- a/unilabos/registry/resources/opentrons/plates.yaml +++ b/unilabos/registry/resources/opentrons/plates.yaml @@ -109,7 +109,7 @@ nest_96_wellplate_100ul_pcr_full_skirt: init_param_schema: {} model: children_mesh: generic_labware_tube_10_75/meshes/0_base.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_tf: - 0.0018 - 0.0018 @@ -125,7 +125,7 @@ nest_96_wellplate_100ul_pcr_full_skirt: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 nest_96_wellplate_200ul_flat: @@ -158,7 +158,7 @@ nest_96_wellplate_2ml_deep: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 thermoscientificnunc_96_wellplate_1300ul: diff --git a/unilabos/registry/resources/opentrons/tip_racks.yaml b/unilabos/registry/resources/opentrons/tip_racks.yaml index d1682b2a..ec838018 100644 --- a/unilabos/registry/resources/opentrons/tip_racks.yaml +++ b/unilabos/registry/resources/opentrons/tip_racks.yaml @@ -69,7 +69,7 @@ opentrons_96_filtertiprack_1000ul: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 opentrons_96_filtertiprack_10ul: diff --git a/unilabos/registry/utils.py b/unilabos/registry/utils.py index 1ab7dd2c..eb342c5c 100644 --- a/unilabos/registry/utils.py +++ b/unilabos/registry/utils.py @@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance from unilabos.utils.cls_creator import import_class +from unilabos.registry.decorators import Side, DataSource, normalize_enum_value _logger = logging.getLogger(__name__) @@ -487,10 +488,7 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]: } side = h.get("side") if side: - if isinstance(side, str) and "." in side: - val = side.rsplit(".", 1)[-1] - side = val.lower() if val in ("LEFT", "RIGHT", "TOP", "BOTTOM") else val - entry["side"] = side + entry["side"] = normalize_enum_value(side, Side) or side label = h.get("label") if label: entry["label"] = label @@ -499,10 +497,7 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]: entry["data_key"] = data_key data_source = h.get("data_source") if data_source: - if isinstance(data_source, str) and "." in data_source: - val = data_source.rsplit(".", 1)[-1] - data_source = val.lower() if val in ("HANDLE", "EXECUTOR") else val - entry["data_source"] = data_source + entry["data_source"] = normalize_enum_value(data_source, DataSource) or data_source description = h.get("description") if description: entry["description"] = description @@ -537,17 +532,12 @@ def normalize_ast_action_handles(handles_raw: Any) -> Dict[str, Any]: "data_type": h.get("data_type", ""), "label": h.get("label", ""), } + _FIELD_ENUM_MAP = {"side": Side, "data_source": DataSource} for opt_key in ("side", "data_key", "data_source", "description", "io_type"): val = h.get(opt_key) if val is not None: - # Only resolve enum-style refs (e.g. DataSource.HANDLE -> handle) for data_source/side - # data_key values like "wells.@flatten", "@this.0@@@plate" must be preserved as-is - if ( - isinstance(val, str) - and "." in val - and opt_key not in ("io_type", "data_key") - ): - val = val.rsplit(".", 1)[-1].lower() + if opt_key in _FIELD_ENUM_MAP: + val = normalize_enum_value(val, _FIELD_ENUM_MAP[opt_key]) or val entry[opt_key] = val # io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it) diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 5a37c4c7..4a6911ca 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -1033,7 +1033,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})") # 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称(去除PLR系统添加的后缀) - # tracker.liquids 格式: [(物料名称, 数量), ...] + # tracker.liquids 格式: [(物料名称, 数量, 单位), ...] material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶") if hasattr(bottle, "tracker") and bottle.tracker.liquids: # 如果有液体,使用液体的名称 @@ -1051,7 +1051,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict "typeId": bottle_type_info[1], "code": bottle.code if hasattr(bottle, "code") else "", "name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶") - "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, + "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "x": bioyond_x, "y": bioyond_y, "z": 1, @@ -1124,7 +1124,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict "barCode": "", "name": material_name, # 使用物料名称而不是资源名称 "unit": default_unit, # 使用配置的单位或默认单位 - "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, + "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "Parameters": parameters_json # API 实际要求的字段(必需) } diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index c24f9e8e..7dca43e8 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -1,4 +1,5 @@ import json +import os # from nt import device_encoding import threading @@ -61,7 +62,7 @@ def main( rclpy.init(args=rclpy_init_args) else: logger.info("[ROS] rclpy already initialized, reusing context") - executor = rclpy.__executor = MultiThreadedExecutor() + executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48)) # 创建主机节点 host_node = HostNode( "host_node", @@ -122,7 +123,7 @@ def slave( rclpy.init(args=rclpy_init_args) executor = rclpy.__executor if not executor: - executor = rclpy.__executor = MultiThreadedExecutor() + executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48)) # 1.5 启动 executor 线程 thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread") diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index ffc106c7..4fa6b1c5 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -4,6 +4,8 @@ import json import threading import time import traceback + +from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads from typing import ( get_type_hints, TypeVar, @@ -78,6 +80,67 @@ if TYPE_CHECKING: T = TypeVar("T") +class RclpyAsyncMutex: + """rclpy executor 兼容的异步互斥锁 + + 通过 executor.create_task 唤醒等待者,避免 timer 的 InvalidHandle 问题。 + """ + + def __init__(self, name: str = ""): + self._lock = threading.Lock() + self._acquired = False + self._queue: List[Future] = [] + self._name = name + self._holder: Optional[str] = None + + async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""): + """获取锁。如果已被占用,则异步等待直到锁释放。""" + # t0 = time.time() + with self._lock: + # qlen = len(self._queue) + if not self._acquired: + self._acquired = True + self._holder = tag + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 获取锁 tag={tag} (无等待, queue=0)" + # ) + return + waiter = Future() + self._queue.append(waiter) + # node.lab_logger().info( + # f"[Mutex:{self._name}] 等待锁 tag={tag} " + # f"(holder={self._holder}, queue={qlen + 1})" + # ) + await waiter + # wait_ms = (time.time() - t0) * 1000 + self._holder = tag + # node.lab_logger().info( + # f"[Mutex:{self._name}] 获取锁 tag={tag} (等了 {wait_ms:.0f}ms)" + # ) + + def release(self, node: "BaseROS2DeviceNode"): + """释放锁,通过 executor task 唤醒下一个等待者。""" + with self._lock: + # old_holder = self._holder + if self._queue: + next_waiter = self._queue.pop(0) + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 唤醒下一个 (剩余 queue={len(self._queue)})" + # ) + + async def _wake(): + if not next_waiter.done(): + next_waiter.set_result(None) + + rclpy.get_global_executor().create_task(_wake()) + else: + self._acquired = False + self._holder = None + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 空闲" + # ) + + # 在线设备注册表 registered_devices: Dict[str, "DeviceInfoType"] = {} @@ -355,6 +418,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}" ) + self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}") + # 创建资源管理客户端 self._resource_clients: Dict[str, Client] = { "resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group), @@ -378,15 +443,40 @@ class BaseROS2DeviceNode(Node, Generic[T]): return res async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response): + _cmd = _fast_loads(req.command) + _res_name = _cmd.get("resource", [{}]) + _res_name = (_res_name[0].get("id", "?") if isinstance(_res_name, list) and _res_name + else _res_name.get("id", "?") if isinstance(_res_name, dict) else "?") + _ar_tag = f"{_res_name}" + # _t_enter = time.time() + # self.lab_logger().info(f"[AR:{_ar_tag}] 进入 append_resource") + await self._append_resource_lock.acquire(self, tag=_ar_tag) + # _t_locked = time.time() + try: + return await _append_resource_inner(req, res, _ar_tag) + # _t_done = time.time() + # self.lab_logger().info( + # f"[AR:{_ar_tag}] 完成 " + # f"等锁={(_t_locked - _t_enter) * 1000:.0f}ms " + # f"执行={(_t_done - _t_locked) * 1000:.0f}ms " + # f"总计={(_t_done - _t_enter) * 1000:.0f}ms" + # ) + except Exception as _ex: + self.lab_logger().error(f"[AR:{_ar_tag}] 异常: {_ex}") + raise + finally: + self._append_resource_lock.release(self) + + async def _append_resource_inner(req: SerialCommand_Request, res: SerialCommand_Response, _ar_tag: str = ""): from pylabrobot.resources.deck import Deck from pylabrobot.resources import Coordinate from pylabrobot.resources import Plate - # 物料传输到对应的node节点 + # _t0 = time.time() client = self._resource_clients["c2s_update_resource_tree"] request = SerialCommand.Request() request2 = SerialCommand.Request() - command_json = json.loads(req.command) + command_json = _fast_loads(req.command) namespace = command_json["namespace"] bind_parent_id = command_json["bind_parent_id"] edge_device_id = command_json["edge_device_id"] @@ -439,7 +529,11 @@ class BaseROS2DeviceNode(Node, Generic[T]): f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}" ) # noinspection PyUnresolvedReferences - request.command = json.dumps( + # _t1 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 准备完成 PLR转换+序列化 {((_t1 - _t0) * 1000):.0f}ms, 发送首次上传..." + # ) + request.command = _fast_dumps_str( { "action": "add", "data": { @@ -450,7 +544,11 @@ class BaseROS2DeviceNode(Node, Generic[T]): } ) tree_response: SerialCommand.Response = await client.call_async(request) - uuid_maps = json.loads(tree_response.response) + # _t2 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 首次上传完成 {((_t2 - _t1) * 1000):.0f}ms" + # ) + uuid_maps = _fast_loads(tree_response.response) plr_instances = rts.to_plr_resources() for plr_instance in plr_instances: self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps) @@ -486,18 +584,12 @@ class BaseROS2DeviceNode(Node, Generic[T]): if len(rts.root_nodes) == 1 and parent_resource is not None: plr_instance = plr_instances[0] if isinstance(plr_instance, Plate): - empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT) LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT) self.lab_logger().warning( f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个" ) - for liquid_type, liquid_volume, liquid_input_slot in zip( - ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT - ): - empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume) - plr_instance.set_well_liquids(empty_liquid_info_in) try: # noinspection PyProtectedMember keys = list(plr_instance._ordering.keys()) @@ -511,6 +603,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): input_wells = [] for r in LIQUID_INPUT_SLOT: input_wells.append(plr_instance.children[r]) + for input_well, liquid_type, liquid_volume, liquid_input_slot in zip( + input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT + ): + input_well.set_liquids([(liquid_type, liquid_volume, "ul")]) final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources( input_wells ).dump() @@ -529,12 +625,13 @@ class BaseROS2DeviceNode(Node, Generic[T]): Coordinate(location["x"], location["y"], location["z"]), **other_calling_param, ) - # 调整了液体以及Deck之后要重新Assign # noinspection PyUnresolvedReferences + # _t3 = time.time() rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource]) + # _n_parent = len(rts_with_parent.all_nodes) if rts_with_parent.root_nodes[0].res_content.uuid_parent is None: rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid - request.command = json.dumps( + request.command = _fast_dumps_str( { "action": "add", "data": { @@ -544,11 +641,18 @@ class BaseROS2DeviceNode(Node, Generic[T]): }, } ) + # _t4 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 二次上传序列化 {_n_parent}节点 {((_t4 - _t3) * 1000):.0f}ms, 发送中..." + # ) tree_response: SerialCommand.Response = await client.call_async(request) - uuid_maps = json.loads(tree_response.response) + # _t5 = time.time() + uuid_maps = _fast_loads(tree_response.response) self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) - self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") - # 这里created_resources不包含parent_resource + # self._lab_logger.info( + # f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms " + # f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms" + # ) # 发送给ResourceMeshManager action_client = ActionClient( self, @@ -685,7 +789,11 @@ class BaseROS2DeviceNode(Node, Generic[T]): ) # 发送请求并等待响应 response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r) + if not response.response: + raise ValueError(f"查询资源 {resource_id} 失败:服务端返回空响应") raw_data = json.loads(response.response) + if not raw_data: + raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空") # 转换为 PLR 资源 tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) @@ -1134,7 +1242,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): if uid is None: raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性,无法转运") target_uids.append(uid) - srv_address = f"/srv{target_device_id}/s2c_resource_tree" + _ns = target_device_id if target_device_id.startswith("/devices/") else f"/devices/{target_device_id.lstrip('/')}" + srv_address = f"/srv{_ns}/s2c_resource_tree" sclient = self.create_client(SerialCommand, srv_address) # 等待服务可用(设置超时) if not sclient.wait_for_service(timeout_sec=5.0): @@ -1184,7 +1293,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): return False time.sleep(0.05) self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}") - return None + return "转运完成" def register_device(self): """向注册表中注册设备信息""" @@ -1256,9 +1365,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): return self._lab_logger def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0): - """创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建。""" - # 检测 @topic_config 装饰器配置 - topic_config = {} + """创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数。""" + topic_cfg = {} driver_class = type(self.driver_instance) # 区分 @property 和普通方法两种情况 @@ -1267,23 +1375,17 @@ class BaseROS2DeviceNode(Node, Generic[T]): ) if is_prop: - # @property: 检测 fget 上的 @topic_config class_attr = getattr(driver_class, attr_name) if class_attr.fget is not None: - topic_config = get_topic_config(class_attr.fget) + topic_cfg = get_topic_config(class_attr.fget) else: - # 普通方法: 直接检测 attr_name 方法上的 @topic_config if hasattr(self.driver_instance, attr_name): method = getattr(self.driver_instance, attr_name) if callable(method): - topic_config = get_topic_config(method) - - # 没有 @topic_config 装饰器则跳过发布 - if not topic_config: - return + topic_cfg = get_topic_config(method) # 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name - cfg_name = topic_config.get("name") + cfg_name = topic_cfg.get("name") if cfg_name: publish_name = cfg_name elif attr_name.startswith("get_"): @@ -1291,10 +1393,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): else: publish_name = attr_name - # 使用装饰器配置或默认值 - cfg_period = topic_config.get("period") - cfg_print = topic_config.get("print_publish") - cfg_qos = topic_config.get("qos") + # @topic_config 参数覆盖默认值 + cfg_period = topic_cfg.get("period") + cfg_print = topic_cfg.get("print_publish") + cfg_qos = topic_cfg.get("qos") period: float = cfg_period if cfg_period is not None else initial_period print_publish: bool = cfg_print if cfg_print is not None else self._print_publish qos: int = cfg_qos if cfg_qos is not None else 10 @@ -1576,37 +1678,75 @@ class BaseROS2DeviceNode(Node, Generic[T]): feedback_msg_types = action_type.Feedback.get_fields_and_field_types() result_msg_types = action_type.Result.get_fields_and_field_types() - while future is not None and not future.done(): - if goal_handle.is_cancel_requested: - self.lab_logger().info(f"取消动作: {action_name}") - future.cancel() # 尝试取消线程池中的任务 - goal_handle.canceled() - return action_type.Result() + # 低频 feedback timer(10s),不阻塞完成检测 + _feedback_timer = None - self._time_spent = time.time() - time_start - self._time_remaining = time_overall - self._time_spent + def _publish_feedback(): + if future is not None and not future.done(): + self._time_spent = time.time() - time_start + self._time_remaining = time_overall - self._time_spent + feedback_values = {} + for msg_name, attr_name in action_value_mapping["feedback"].items(): + if hasattr(self.driver_instance, f"get_{attr_name}"): + method = getattr(self.driver_instance, f"get_{attr_name}") + if not asyncio.iscoroutinefunction(method): + feedback_values[msg_name] = method() + elif hasattr(self.driver_instance, attr_name): + feedback_values[msg_name] = getattr(self.driver_instance, attr_name) + if self._print_publish: + self.lab_logger().info(f"反馈: {feedback_values}") + feedback_msg = convert_to_ros_msg_with_mapping( + ros_msg_type=action_type.Feedback(), + obj=feedback_values, + value_mapping=action_value_mapping["feedback"], + ) + goal_handle.publish_feedback(feedback_msg) - # 发布反馈 - feedback_values = {} - for msg_name, attr_name in action_value_mapping["feedback"].items(): - if hasattr(self.driver_instance, f"get_{attr_name}"): - method = getattr(self.driver_instance, f"get_{attr_name}") - if not asyncio.iscoroutinefunction(method): - feedback_values[msg_name] = method() - elif hasattr(self.driver_instance, attr_name): - feedback_values[msg_name] = getattr(self.driver_instance, attr_name) - - if self._print_publish: - self.lab_logger().info(f"反馈: {feedback_values}") - - feedback_msg = convert_to_ros_msg_with_mapping( - ros_msg_type=action_type.Feedback(), - obj=feedback_values, - value_mapping=action_value_mapping["feedback"], + if action_value_mapping.get("feedback"): + _fb_interval = action_value_mapping.get("feedback_interval", 0.5) + _feedback_timer = self.create_timer( + _fb_interval, _publish_feedback, callback_group=self.callback_group ) - goal_handle.publish_feedback(feedback_msg) - time.sleep(0.5) + # 等待 action 完成 + if future is not None: + if isinstance(future, Task): + # rclpy Task:直接 await,完成瞬间唤醒 + try: + _raw_result = await future + except Exception as e: + _raw_result = e + else: + # concurrent.futures.Future(同步 action):用 rclpy 兼容的轮询 + _poll_future = Future() + + def _on_sync_done(fut): + if not _poll_future.done(): + _poll_future.set_result(None) + + future.add_done_callback(_on_sync_done) + await _poll_future + try: + _raw_result = future.result() + except Exception as e: + _raw_result = e + + # 确保 execution_error/success 被正确设置(不依赖 done callback 时序) + if isinstance(_raw_result, BaseException): + if not execution_error: + execution_error = traceback.format_exception( + type(_raw_result), _raw_result, _raw_result.__traceback__ + ) + execution_error = "".join(execution_error) + execution_success = False + action_return_value = _raw_result + elif not execution_error: + execution_success = True + action_return_value = _raw_result + + # 清理 feedback timer + if _feedback_timer is not None: + _feedback_timer.cancel() if future is not None and future.cancelled(): self.lab_logger().info(f"动作 {action_name} 已取消") @@ -1615,8 +1755,12 @@ class BaseROS2DeviceNode(Node, Generic[T]): # self.lab_logger().info(f"动作执行完成: {action_name}") del future + # 执行失败时跳过物料状态更新 + if execution_error: + execution_success = False + # 向Host更新物料当前状态 - if action_name not in ["create_resource_detailed", "create_resource"]: + if not execution_error and action_name not in ["create_resource_detailed", "create_resource"]: for k, v in goal.get_fields_and_field_types().items(): if v not in ["unilabos_msgs/Resource", "sequence"]: continue @@ -1672,7 +1816,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): for attr_name in result_msg_types.keys(): if attr_name in ["success", "reached_goal"]: - setattr(result_msg, attr_name, True) + setattr(result_msg, attr_name, execution_success) elif attr_name == "return_info": setattr( result_msg, @@ -1778,7 +1922,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): raise ValueError("至少需要提供一个 UUID") uuids_list = list(uuids) - future = self._resource_clients["c2s_update_resource_tree"].call_async( + future: Future = self._resource_clients["c2s_update_resource_tree"].call_async( SerialCommand.Request( command=json.dumps( { @@ -1804,6 +1948,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): raise Exception(f"资源查询返回空结果: {uuids_list}") raw_data = json.loads(response.response) + if not raw_data: + raise Exception(f"资源原始查询返回空结果: {raw_data}") # 转换为 PLR 资源 tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) @@ -1921,16 +2067,27 @@ class BaseROS2DeviceNode(Node, Generic[T]): f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" ) - async def _convert_resource_async(self, resource_data: Dict[str, Any]): - """异步转换资源数据为实例""" - # 使用封装的get_resource_with_dir方法获取PLR资源 - plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True) + async def _convert_resource_async(self, resource_data: "ResourceDictType"): + """异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询""" + unilabos_uuid = resource_data.get("uuid") + + if unilabos_uuid: + resource_tree = await self.get_resource([unilabos_uuid], with_children=True) + plr_resources = resource_tree.to_plr_resources() + if plr_resources: + plr_resource = plr_resources[0] + else: + raise ValueError(f"通过 uuid={unilabos_uuid} 查询资源为空") + else: + res_id = resource_data.get("id") or resource_data.get("name", "") + if not res_id: + raise ValueError(f"资源数据缺少 uuid 和 id: {list(resource_data.keys())}") + plr_resource = await self.get_resource_with_dir(resource_id=res_id, with_children=True) # 通过资源跟踪器获取本地实例 res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) if len(res) == 0: - # todo: 后续通过decoration来区分,减少warning - self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例") + self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例") return plr_resource elif len(res) == 1: return res[0] diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index eb139f1f..26b925bb 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -4,6 +4,8 @@ import threading import time import traceback import uuid + +from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads from dataclasses import dataclass, field from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union @@ -24,7 +26,7 @@ from unilabos_msgs.srv import ( from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unique_identifier_msgs.msg import UUID -from unilabos.registry.decorators import device +from unilabos.registry.decorators import device, action, NodeType from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot from unilabos.registry.registry import lab_registry from unilabos.resources.container import RegularContainer @@ -313,7 +315,9 @@ class HostNode(BaseROS2DeviceNode): callback_group=self.callback_group, ), } # 用来存储多个ActionClient实例 - self._action_value_mappings: Dict[str, Dict] = {} # device_id -> action_value_mappings(本地+远程设备统一存储) + self._action_value_mappings: Dict[str, Dict] = { + device_id: self._action_value_mappings + } # device_id -> action_value_mappings(本地+远程设备统一存储) self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings) self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 @@ -616,22 +620,17 @@ class HostNode(BaseROS2DeviceNode): } ) ] - response: List[str] = await self.create_resource_detailed( resources, device_ids, bind_parent_id, bind_location, other_calling_param ) - try: - assert len(response) == 1, "Create Resource应当只返回一个结果" - for i in response: - res = json.loads(i) - if "suc" in res: - raise ValueError(res.get("error")) - return res - except Exception as ex: - pass - _n = "\n" - raise ValueError(f"创建资源时失败!\n{_n.join(response)}") + assert len(response) == 1, "Create Resource应当只返回一个结果" + for i in response: + res = json.loads(i) + if "suc" in res and not res["suc"]: + raise ValueError(res.get("error", "未知错误")) + return res + raise ValueError(f"创建资源时失败!响应为空") def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None: """ @@ -1166,7 +1165,7 @@ class HostNode(BaseROS2DeviceNode): else: physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {})) - response.response = json.dumps(uuid_mapping) if success else "FAILED" + response.response = _fast_dumps_str(uuid_mapping) if success else "FAILED" self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK @@ -1176,6 +1175,7 @@ class HostNode(BaseROS2DeviceNode): resource_response = http_client.resource_tree_get(uuid_list, with_children) response.response = json.dumps(resource_response) + self.lab_logger().trace(f"[Host Node-Resource] Resource tree get request callback {response.response}") async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response): """ @@ -1228,9 +1228,26 @@ class HostNode(BaseROS2DeviceNode): """ try: # 解析请求数据 - data = json.loads(request.command) + data = _fast_loads(request.command) action = data["action"] - self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received") + inner = data.get("data", {}) + if action == "add": + mount_uuid = inner.get("mount_uuid", "?")[:8] if isinstance(inner, dict) else "?" + tree_data = inner.get("data", []) if isinstance(inner, dict) else inner + node_count = len(tree_data) if isinstance(tree_data, list) else "?" + source = f"mount={mount_uuid}.. nodes≈{node_count}" + elif action in ("get", "remove"): + uid_list = inner.get("data", inner) if isinstance(inner, dict) else inner + source = f"uuids={len(uid_list) if isinstance(uid_list, list) else '?'}" + elif action == "update": + tree_data = inner.get("data", []) if isinstance(inner, dict) else inner + node_count = len(tree_data) if isinstance(tree_data, list) else "?" + source = f"nodes≈{node_count}" + else: + source = "" + self.lab_logger().info( + f"[Host Node-Resource] Resource tree {action} request received ({source})" + ) data = data["data"] if action == "add": await self._resource_tree_action_add_callback(data, response) @@ -1621,6 +1638,19 @@ class HostNode(BaseROS2DeviceNode): } return res + @action(always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={ + "assignee_user_ids": "unilabos_manual_confirm" + }, goal_default={ + "timeout_seconds": 3600, + "assignee_user_ids": [] + }) + def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict: + """ + timeout_seconds: 超时时间(秒),默认3600秒 + 修改的结果无效,是只读的 + """ + return kwargs + def test_resource( self, sample_uuids: SampleUUIDsType, diff --git a/unilabos/test/experiments/virtual_bench.json b/unilabos/test/experiments/virtual_bench.json index d37fa6ee..0cffe842 100644 --- a/unilabos/test/experiments/virtual_bench.json +++ b/unilabos/test/experiments/virtual_bench.json @@ -22,6 +22,447 @@ "arm_state": "idle", "message": "工作台就绪" } + }, + { + "id": "PRCXI", + "name": "PRCXI", + "type": "device", + "class": "liquid_handler.prcxi", + "parent": "", + "pose": { + "size": { + "width": 562, + "height": 394, + "depth": 0 + } + }, + "config": { + "axis": "Left", + "deck": { + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck", + "_resource_child_name": "PRCXI_Deck" + }, + "host": "10.20.30.184", + "port": 9999, + "debug": true, + "setup": true, + "is_9320": true, + "timeout": 10, + "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", + "simulator": true, + "channel_num": 2 + }, + "data": { + "reset_ok": true + }, + "schema": {}, + "description": "", + "model": null, + "position": { + "x": 0, + "y": 240, + "z": 0 + } + }, + { + "id": "PRCXI_Deck", + "name": "PRCXI_Deck", + "children": [], + "parent": "PRCXI", + "type": "deck", + "class": "", + "position": { + "x": 10, + "y": 10, + "z": 0 + }, + "config": { + "type": "PRCXI9300Deck", + "size_x": 542, + "size_y": 374, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "deck", + "barcode": null, + "preferred_pickup_location": null, + "sites": [ + { + "label": "T1", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "container", + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T2", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T3", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T4", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T5", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T6", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T7", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T8", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T9", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T10", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T11", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T12", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T13", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T14", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T15", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T16", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + } + ] + }, + "data": {} } ], "links": [] diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index a2bbd262..366694be 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -6,20 +6,180 @@ import argparse import importlib import locale +import shutil import subprocess import sys +from pathlib import Path +from typing import List, Optional from unilabos.utils.banner_print import print_status +# --------------------------------------------------------------------------- +# 底层安装工具 +# --------------------------------------------------------------------------- + +def _is_chinese_locale() -> bool: + try: + lang = locale.getdefaultlocale()[0] + return bool(lang and ("zh" in lang.lower() or "chinese" in lang.lower())) + except Exception: + return False + + +_USE_UV: Optional[bool] = None + + +def _has_uv() -> bool: + global _USE_UV + if _USE_UV is None: + _USE_UV = shutil.which("uv") is not None + return _USE_UV + + +def _install_packages( + packages: List[str], + upgrade: bool = False, + label: str = "", +) -> bool: + """ + 安装/升级一组包。优先 uv pip install,回退 sys pip。 + 逐个安装,任意一个失败不影响后续包。 + + Returns: + True if all succeeded, False otherwise. + """ + if not packages: + return True + + is_chinese = _is_chinese_locale() + use_uv = _has_uv() + failed: List[str] = [] + + for pkg in packages: + action_word = "升级" if upgrade else "安装" + if label: + print_status(f"[{label}] 正在{action_word} {pkg}...", "info") + else: + print_status(f"正在{action_word} {pkg}...", "info") + + if use_uv: + cmd = ["uv", "pip", "install"] + if upgrade: + cmd.append("--upgrade") + cmd.append(pkg) + if is_chinese: + cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + else: + cmd = [sys.executable, "-m", "pip", "install"] + if upgrade: + cmd.append("--upgrade") + cmd.append(pkg) + if is_chinese: + cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode == 0: + installer = "uv" if use_uv else "pip" + print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") + else: + stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" + print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error") + failed.append(pkg) + except subprocess.TimeoutExpired: + print_status(f"× {pkg} {action_word}超时 (300s)", "error") + failed.append(pkg) + except Exception as e: + print_status(f"× {pkg} {action_word}异常: {e}", "error") + failed.append(pkg) + + if failed: + print_status(f"有 {len(failed)} 个包操作失败: {', '.join(failed)}", "error") + return False + return True + + +# --------------------------------------------------------------------------- +# requirements.txt 安装(可多次调用) +# --------------------------------------------------------------------------- + +def install_requirements_txt(req_path: str | Path, label: str = "") -> bool: + """ + 读取一个 requirements.txt 文件,检查缺失的包并安装。 + + Args: + req_path: requirements.txt 文件路径 + label: 日志前缀标签(如 "device_package_sim") + + Returns: + True if all ok, False if any install failed. + """ + req_path = Path(req_path) + if not req_path.exists(): + return True + + tag = label or req_path.parent.name + print_status(f"[{tag}] 检查依赖: {req_path}", "info") + + reqs: List[str] = [] + with open(req_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and not line.startswith("-"): + reqs.append(line) + + if not reqs: + return True + + missing: List[str] = [] + for req in reqs: + pkg_import = req.split(">=")[0].split("==")[0].split("<")[0].split("[")[0].split(">")[0].strip() + pkg_import = pkg_import.replace("-", "_") + try: + importlib.import_module(pkg_import) + except ImportError: + missing.append(req) + + if not missing: + print_status(f"[{tag}] ✓ 依赖检查通过 ({len(reqs)} 个包)", "success") + return True + + print_status(f"[{tag}] 缺失 {len(missing)} 个依赖: {', '.join(missing)}", "warning") + return _install_packages(missing, label=tag) + + +def check_device_package_requirements(devices_dirs: list[str]) -> bool: + """ + 检查 --devices 指定的所有外部设备包目录中的 requirements.txt。 + 对每个目录查找 requirements.txt(先在目录内找,再在父目录找)。 + """ + if not devices_dirs: + return True + + all_ok = True + for d in devices_dirs: + d_path = Path(d).resolve() + req_file = d_path / "requirements.txt" + if not req_file.exists(): + req_file = d_path.parent / "requirements.txt" + if not req_file.exists(): + continue + if not install_requirements_txt(req_file, label=d_path.name): + all_ok = False + + return all_ok + + +# --------------------------------------------------------------------------- +# UniLabOS 核心环境检查 +# --------------------------------------------------------------------------- + class EnvironmentChecker: """环境检查器""" def __init__(self): - # 定义必需的包及其安装名称的映射 self.required_packages = { - # 包导入名 : pip安装名 - # "pymodbus.framer.FramerType": "pymodbus==3.9.2", "websockets": "websockets", "msgcenterpy": "msgcenterpy", "orjson": "orjson", @@ -28,33 +188,17 @@ class EnvironmentChecker: "crcmod": "crcmod-plus", } - # 特殊安装包(需要特殊处理的包) self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"} - # 包版本要求(包名: 最低版本) self.version_requirements = { - "msgcenterpy": "0.1.8", # msgcenterpy 最低版本要求 + "msgcenterpy": "0.1.8", } - self.missing_packages = [] - self.failed_installs = [] - self.packages_need_upgrade = [] - - # 检测系统语言 - self.is_chinese = self._is_chinese_locale() - - def _is_chinese_locale(self) -> bool: - """检测系统是否为中文环境""" - try: - lang = locale.getdefaultlocale()[0] - if lang and ("zh" in lang.lower() or "chinese" in lang.lower()): - return True - except Exception: - pass - return False + self.missing_packages: List[tuple] = [] + self.failed_installs: List[tuple] = [] + self.packages_need_upgrade: List[tuple] = [] def check_package_installed(self, package_name: str) -> bool: - """检查包是否已安装""" try: importlib.import_module(package_name) return True @@ -62,7 +206,6 @@ class EnvironmentChecker: return False def get_package_version(self, package_name: str) -> str | None: - """获取已安装包的版本""" try: module = importlib.import_module(package_name) return getattr(module, "__version__", None) @@ -70,88 +213,32 @@ class EnvironmentChecker: return None def compare_version(self, current: str, required: str) -> bool: - """ - 比较版本号 - Returns: - True: current >= required - False: current < required - """ try: current_parts = [int(x) for x in current.split(".")] required_parts = [int(x) for x in required.split(".")] - - # 补齐长度 max_len = max(len(current_parts), len(required_parts)) current_parts.extend([0] * (max_len - len(current_parts))) required_parts.extend([0] * (max_len - len(required_parts))) - return current_parts >= required_parts except Exception: - return True # 如果无法比较,假设版本满足要求 - - def install_package(self, package_name: str, pip_name: str, upgrade: bool = False) -> bool: - """安装包""" - try: - action = "升级" if upgrade else "安装" - print_status(f"正在{action} {package_name} ({pip_name})...", "info") - - # 构建安装命令 - cmd = [sys.executable, "-m", "pip", "install"] - - # 如果是升级操作,添加 --upgrade 参数 - if upgrade: - cmd.append("--upgrade") - - cmd.append(pip_name) - - # 如果是中文环境,使用清华镜像源 - if self.is_chinese: - cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) - - # 执行安装 - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) # 5分钟超时 - - if result.returncode == 0: - print_status(f"✓ {package_name} {action}成功", "success") - return True - else: - print_status(f"× {package_name} {action}失败: {result.stderr}", "error") - return False - - except subprocess.TimeoutExpired: - print_status(f"× {package_name} {action}超时", "error") - return False - except Exception as e: - print_status(f"× {package_name} {action}异常: {str(e)}", "error") - return False - - def upgrade_package(self, package_name: str, pip_name: str) -> bool: - """升级包""" - return self.install_package(package_name, pip_name, upgrade=True) + return True def check_all_packages(self) -> bool: - """检查所有必需的包""" print_status("开始检查环境依赖...", "info") - # 检查常规包 for import_name, pip_name in self.required_packages.items(): if not self.check_package_installed(import_name): self.missing_packages.append((import_name, pip_name)) - else: - # 检查版本要求 - if import_name in self.version_requirements: - current_version = self.get_package_version(import_name) - required_version = self.version_requirements[import_name] + elif import_name in self.version_requirements: + current_version = self.get_package_version(import_name) + required_version = self.version_requirements[import_name] + if current_version and not self.compare_version(current_version, required_version): + print_status( + f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})", + "warning", + ) + self.packages_need_upgrade.append((import_name, pip_name)) - if current_version: - if not self.compare_version(current_version, required_version): - print_status( - f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})", - "warning", - ) - self.packages_need_upgrade.append((import_name, pip_name)) - - # 检查特殊包 for package_name, install_url in self.special_packages.items(): if not self.check_package_installed(package_name): self.missing_packages.append((package_name, install_url)) @@ -170,7 +257,6 @@ class EnvironmentChecker: return False def install_missing_packages(self, auto_install: bool = True) -> bool: - """安装缺失的包""" if not self.missing_packages and not self.packages_need_upgrade: return True @@ -178,62 +264,36 @@ class EnvironmentChecker: if self.missing_packages: print_status("缺失以下包:", "warning") for import_name, pip_name in self.missing_packages: - print_status(f" - {import_name} (pip install {pip_name})", "warning") + print_status(f" - {import_name} ({pip_name})", "warning") if self.packages_need_upgrade: print_status("需要升级以下包:", "warning") for import_name, pip_name in self.packages_need_upgrade: - print_status(f" - {import_name} (pip install --upgrade {pip_name})", "warning") + print_status(f" - {import_name} ({pip_name})", "warning") return False - # 安装缺失的包 if self.missing_packages: - print_status(f"开始自动安装 {len(self.missing_packages)} 个缺失的包...", "info") + pkgs = [pip_name for _, pip_name in self.missing_packages] + if not _install_packages(pkgs, label="unilabos"): + self.failed_installs.extend(self.missing_packages) - success_count = 0 - for import_name, pip_name in self.missing_packages: - if self.install_package(import_name, pip_name): - success_count += 1 - else: - self.failed_installs.append((import_name, pip_name)) - - print_status(f"✓ 成功安装 {success_count}/{len(self.missing_packages)} 个包", "success") - - # 升级需要更新的包 if self.packages_need_upgrade: - print_status(f"开始自动升级 {len(self.packages_need_upgrade)} 个包...", "info") + pkgs = [pip_name for _, pip_name in self.packages_need_upgrade] + if not _install_packages(pkgs, upgrade=True, label="unilabos"): + self.failed_installs.extend(self.packages_need_upgrade) - upgrade_success_count = 0 - for import_name, pip_name in self.packages_need_upgrade: - if self.upgrade_package(import_name, pip_name): - upgrade_success_count += 1 - else: - self.failed_installs.append((import_name, pip_name)) - - print_status(f"✓ 成功升级 {upgrade_success_count}/{len(self.packages_need_upgrade)} 个包", "success") - - if self.failed_installs: - print_status(f"有 {len(self.failed_installs)} 个包操作失败:", "error") - for import_name, pip_name in self.failed_installs: - print_status(f" - {import_name} ({pip_name})", "error") - return False - - return True + return not self.failed_installs def verify_installation(self) -> bool: - """验证安装结果""" if not self.missing_packages and not self.packages_need_upgrade: return True print_status("验证安装结果...", "info") - failed_verification = [] - # 验证新安装的包 for import_name, pip_name in self.missing_packages: if not self.check_package_installed(import_name): failed_verification.append((import_name, pip_name)) - # 验证升级的包 for import_name, pip_name in self.packages_need_upgrade: if not self.check_package_installed(import_name): failed_verification.append((import_name, pip_name)) @@ -270,17 +330,14 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b """ checker = EnvironmentChecker() - # 检查包 if checker.check_all_packages(): return True - # 安装缺失的包 if not checker.install_missing_packages(auto_install): if show_details: print_status("请手动安装缺失的包后重新启动程序", "error") return False - # 验证安装 if not checker.verify_installation(): if show_details: print_status("安装验证失败,请检查网络连接或手动安装", "error") @@ -290,14 +347,12 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b if __name__ == "__main__": - # 命令行参数解析 parser = argparse.ArgumentParser(description="UniLabOS 环境依赖检查工具") parser.add_argument("--no-auto-install", action="store_true", help="仅检查环境,不自动安装缺失的包") parser.add_argument("--silent", action="store_true", help="静默模式,不显示详细信息") args = parser.parse_args() - # 执行环境检查 auto_install = not args.no_auto_install show_details = not args.silent diff --git a/unilabos/utils/tools.py b/unilabos/utils/tools.py index 3c7b742e..e6719208 100644 --- a/unilabos/utils/tools.py +++ b/unilabos/utils/tools.py @@ -17,6 +17,14 @@ try: default=json_default, ) + def fast_loads(data) -> dict: + """JSON 反序列化,优先使用 orjson。接受 str / bytes。""" + return orjson.loads(data) + + def fast_dumps_str(obj, **kwargs) -> str: + """JSON 序列化为 str,优先使用 orjson。用于需要 str 而非 bytes 的场景(如 ROS msg)。""" + return orjson.dumps(obj, option=orjson.OPT_NON_STR_KEYS, default=json_default).decode("utf-8") + def normalize_json(info: dict) -> dict: """经 JSON 序列化/反序列化一轮来清理非标准类型。""" return orjson.loads(orjson.dumps(info, default=json_default)) @@ -29,6 +37,14 @@ except ImportError: def fast_dumps_pretty(obj, **kwargs) -> bytes: # type: ignore[misc] return json.dumps(obj, indent=2, ensure_ascii=False, cls=TypeEncoder).encode("utf-8") + def fast_loads(data) -> dict: # type: ignore[misc] + if isinstance(data, bytes): + data = data.decode("utf-8") + return json.loads(data) + + def fast_dumps_str(obj, **kwargs) -> str: # type: ignore[misc] + return json.dumps(obj, ensure_ascii=False, cls=TypeEncoder) + def normalize_json(info: dict) -> dict: # type: ignore[misc] return json.loads(json.dumps(info, ensure_ascii=False, cls=TypeEncoder)) diff --git a/unilabos/utils/type_check.py b/unilabos/utils/type_check.py index e3df2dc2..5477eca3 100644 --- a/unilabos/utils/type_check.py +++ b/unilabos/utils/type_check.py @@ -80,11 +80,12 @@ def get_result_info_str(error: str, suc: bool, return_value=None) -> str: Returns: JSON字符串格式的结果信息 """ - samples = None - if isinstance(return_value, dict): - if "samples" in return_value: - samples = return_value.pop("samples") - result_info = {"error": error, "suc": suc, "return_value": return_value, "samples": samples} + # 请在返回的字典中使用 unilabos_samples进行返回 + # samples = None + # if isinstance(return_value, dict): + # if "samples" in return_value and type(return_value["samples"]) in [list, tuple] and type(return_value["samples"][0]) == dict: + # samples = return_value.pop("samples") + result_info = {"error": error, "suc": suc, "return_value": return_value} return json.dumps(result_info, ensure_ascii=False, cls=ResultInfoEncoder) diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 3e2fec92..e0efad56 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -346,7 +346,7 @@ def refactor_data( "template_name": template_name, "resource_name": resource_name, "description": step.get("description", step.get("purpose", f"{operation} operation")), - "lab_node_type": "Device", + "lab_node_type": "ILab", "param": step.get("parameters", step.get("action_args", {})), "footer": f"{template_name}-{resource_name}", }