diff --git a/.conda/base/recipe.yaml b/.conda/base/recipe.yaml index a63dda77..e37e3ab1 100644 --- a/.conda/base/recipe.yaml +++ b/.conda/base/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos - version: 0.10.19 + version: 0.11.1 source: path: ../../unilabos @@ -54,7 +54,7 @@ requirements: - pymodbus - matplotlib - pylibftdi - - uni-lab::unilabos-env ==0.10.19 + - uni-lab::unilabos-env ==0.11.1 about: repository: https://github.com/deepmodeling/Uni-Lab-OS diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml index e9fd3e24..13ee9f88 100644 --- a/.conda/environment/recipe.yaml +++ b/.conda/environment/recipe.yaml @@ -2,7 +2,7 @@ package: name: unilabos-env - version: 0.10.19 + version: 0.11.1 build: noarch: generic diff --git a/.conda/full/recipe.yaml b/.conda/full/recipe.yaml index ab0e0c9f..7202ad9f 100644 --- a/.conda/full/recipe.yaml +++ b/.conda/full/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos-full - version: 0.10.19 + version: 0.11.1 build: noarch: generic @@ -11,7 +11,7 @@ build: requirements: run: # Base unilabos package (includes unilabos-env) - - uni-lab::unilabos ==0.10.19 + - uni-lab::unilabos ==0.11.1 # Documentation tools - sphinx - sphinx_rtd_theme diff --git a/.cursor/skills/batch-insert-reagent/SKILL.md b/.cursor/skills/batch-insert-reagent/SKILL.md index cd946cc3..3df13fd3 100644 --- a/.cursor/skills/batch-insert-reagent/SKILL.md +++ b/.cursor/skills/batch-insert-reagent/SKILL.md @@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{ ### 2. --addr → BASE URL -| `--addr` 值 | BASE | -|-------------|------| -| `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` | +| `--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 " @@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 @@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ``` 返回成功时包含试剂 UUID: + ```json {"code": 0, "data": {"uuid": "xxx", ...}} ``` @@ -98,28 +100,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ## 试剂字段说明 -| 字段 | 类型 | 必填 | 说明 | 示例 | -|------|------|------|------|------| -| `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"` | +| 字段 | 类型 | 必填 | 说明 | 示例 | +| ------------------- | ------ | ---- | ----------------------------- | ------------------------ | +| `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"` | 克 | +| `"L"` | 升 | +| `"g"` | 克 | | `"kg"` | 千克 | -| `"瓶"` | 瓶 | +| `"瓶"` | 瓶 | > 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。 @@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ```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"} + { + "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" + } ] ``` @@ -160,9 +176,20 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat 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 条」 @@ -172,28 +199,29 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat ## 常见试剂速查表 -| 名称 | CAS | 分子式 | SMILES | -|------|-----|--------|--------| -| 水 | 7732-18-3 | H2O | O | -| 乙醇 | 64-17-5 | C2H6O | CCO | -| 甲醇 | 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 | +| 名称 | 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 应根据化学知识推断或提示用户补充。 diff --git a/.cursor/skills/batch-submit-experiment/SKILL.md b/.cursor/skills/batch-submit-experiment/SKILL.md index 76e1ab1c..0a368ba3 100644 --- a/.cursor/skills/batch-submit-experiment/SKILL.md +++ b/.cursor/skills/batch-submit-experiment/SKILL.md @@ -1,11 +1,13 @@ --- name: batch-submit-experiment -description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds. Use when the user wants to submit experiments, create notebooks, batch run workflows, or mentions 提交实验/批量实验/notebook/实验轮次. +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 批量提交实验指南 -通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。 +通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 ## 前置条件(缺一不可) @@ -18,25 +20,28 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w 生成 AUTH token(任选一种方式): ```bash -# 方式一:Python 一行生成 +# 方式一: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://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | +| `--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>" ``` @@ -44,22 +49,23 @@ AUTH="Authorization: Lab <上面命令输出的 token>" **批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。** -按优先级搜索: +**必须先用 Glob 工具搜索文件**,不要直接猜测路径: ``` -/unilabos_data/req_device_registry_upload.json -/req_device_registry_upload.json +Glob: **/req_device_registry_upload.json ``` -也可直接 Glob 搜索:`**/req_device_registry_upload.json` +常见位置(仅供参考,以 Glob 实际结果为准): +- `/unilabos_data/req_device_registry_upload.json` +- `/req_device_registry_upload.json` 找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。 -**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。 +**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。 ### 4. workflow_uuid(目标工作流) -用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #2 列出可用 workflow 供选择。 +用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。 **四项全部就绪后才可开始。** @@ -68,8 +74,9 @@ AUTH="Authorization: Lab <上面命令输出的 token>" 在整个对话过程中,agent 需要记住以下状态,避免重复询问用户: - `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**) - `workflow_uuid` — 工作流 UUID(用户提供或从列表选择) -- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #3 获取) +- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取) ## 请求约定 @@ -92,12 +99,46 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 -### 2. 列出可用 workflow +### 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" @@ -105,13 +146,14 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid 返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。 -### 3. 获取 workflow 模板详情 +### 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`) @@ -119,7 +161,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A > **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。 -### 4. 提交实验(创建 notebook) +### 5. 提交实验(创建 notebook) ```bash curl -s -X POST "$BASE/api/v1/lab/notebook" \ @@ -131,34 +173,45 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \ ```json { - "lab_uuid": "", - "workflow_uuid": "", - "name": "<实验名称>", - "node_params": [ + "lab_uuid": "", + "project_uuid": "", + "workflow_uuid": "", + "name": "<实验名称>", + "node_params": [ + { + "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], + "datas": [ { - "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], - "datas": [ - { - "node_uuid": "", - "param": {}, - "sample_params": [ - { - "container_uuid": "<容器UUID>", - "sample_value": { - "liquid_names": "<液体名称>", - "volumes": 1000 - } - } - ] - } - ] + "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 请求体详解 @@ -172,25 +225,25 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \ ### 每轮的字段 -| 字段 | 类型 | 说明 | -|------|------|------| +| 字段 | 类型 | 说明 | +| -------------- | ------------- | ----------------------------------------- | | `sample_uuids` | array\ | 该轮实验的样品 UUID 数组,无样品时传 `[]` | -| `datas` | array | 该轮中每个 workflow 节点的参数配置 | +| `datas` | array | 该轮中每个 workflow 节点的参数配置 | ### datas 中每个节点 -| 字段 | 类型 | 说明 | -|------|------|------| -| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #3 获取) | -| `param` | object | 动作参数(根据本地注册表 schema 填写) | -| `sample_params` | array | 样品相关参数(液体名、体积等) | +| 字段 | 类型 | 说明 | +| --------------- | ------ | -------------------------------------------- | +| `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}` | +| 字段 | 类型 | 说明 | +| ---------------- | ------ | ---------------------------------------------------- | +| `container_uuid` | string | 容器 UUID | +| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` | --- @@ -211,6 +264,7 @@ python scripts/gen_notebook_params.py \ > 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。 脚本会: + 1. 调用 workflow detail API 获取所有 action 节点 2. 读取本地注册表,为每个节点查找对应的 action schema 3. 生成 `notebook_template.json`,包含: @@ -222,7 +276,7 @@ python scripts/gen_notebook_params.py \ 如果脚本不可用或注册表不存在: -1. 调用 API #3 获取 workflow 详情 +1. 调用 API #4 获取 workflow 详情 2. 找到每个 action 节点的 `node_uuid` 3. 在本地注册表中查找对应设备的 `action_value_mappings`: ``` @@ -248,8 +302,11 @@ python scripts/gen_notebook_params.py \ "properties": { "goal": { "properties": { - "asp_vols": {"type": "array", "items": {"type": "number"}}, - "sources": {"type": "array"} + "asp_vols": { + "type": "array", + "items": { "type": "number" } + }, + "sources": { "type": "array" } }, "required": ["asp_vols", "sources"] } @@ -275,13 +332,15 @@ Task Progress: - [ ] Step 1: 确认 ak/sk → 生成 AUTH token - [ ] Step 2: 确认 --addr → 设置 BASE URL - [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid -- [ ] Step 4: 确认 workflow_uuid(用户提供或从 GET #2 列表选择) -- [ ] Step 5: GET workflow detail (#3) → 提取各节点 uuid、设备ID、动作名 -- [ ] Step 6: 定位本地注册表 req_device_registry_upload.json -- [ ] Step 7: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板 -- [ ] Step 8: 引导用户填写每轮的参数(sample_uuids、param、sample_params) -- [ ] Step 9: 构建完整请求体 → POST /lab/notebook 提交 -- [ ] Step 10: 检查返回结果,确认提交成功 +- [ ] 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 状态,确认已调度 ``` --- diff --git a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py index 4b984851..a6cbea86 100644 --- a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py +++ b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py @@ -7,7 +7,7 @@ 选项: --auth Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀) - --base API 基础 URL(如 https://uni-lab.test.bohrium.com) + --base API 基础 URL(如 https://leap-lab.test.bohrium.com) --workflow-uuid 目标 workflow 的 UUID --registry 本地注册表文件路径(默认自动搜索) --rounds 实验轮次数(默认 1) @@ -17,7 +17,7 @@ 示例: python gen_notebook_params.py \\ --auth YTFmZDlkNGUtxxxx \\ - --base https://uni-lab.test.bohrium.com \\ + --base https://leap-lab.test.bohrium.com \\ --workflow-uuid abc-123-def \\ --rounds 2 """ @@ -265,6 +265,7 @@ def generate_template(nodes, registry_index, rounds): return { "lab_uuid": "$TODO_LAB_UUID", + "project_uuid": "$TODO_PROJECT_UUID", "workflow_uuid": "$TODO_WORKFLOW_UUID", "name": "$TODO_EXPERIMENT_NAME", "node_params": node_params, diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index c01a2e37..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 模板,修改: + - 设备名称 - Action 数量 - 目录列表 @@ -171,46 +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 ## 请求约定 + - 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: ""} -# - #10 获取资源树 GET /lab/material/download/{lab_uuid} + +# 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` 包含 API endpoint(#1 获取 lab_uuid、#2-#9 工作流/动作、#10 资源树) -- [ ] `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` 字段 @@ -256,67 +294,198 @@ API 模板结构: ## 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 index 18923711..b94a0aaf 100644 --- a/.cursor/skills/submit-agent-result/SKILL.md +++ b/.cursor/skills/submit-agent-result/SKILL.md @@ -1,11 +1,13 @@ --- name: submit-agent-result -description: Submit historical experiment results (agent_result) to Uni-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结果. +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 提交历史实验记录指南 -通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。 +通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 ## 前置条件(缺一不可) @@ -18,23 +20,26 @@ description: Submit historical experiment results (agent_result) to Uni-Lab note 生成 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 `。 +输出即为 token 值,拼接为 `Authorization: Lab `(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。 ### 2. --addr → BASE URL -| `--addr` 值 | BASE | -|-------------|------| -| `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` | +| `--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>" ``` @@ -45,6 +50,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>" notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。 如果用户不记得,可提示: + - 查看之前的对话记录中创建 notebook 时返回的 UUID - 或通过平台页面查找对应的 notebook @@ -54,11 +60,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次, 用户需要提供实验结果数据,支持以下方式: -| 方式 | 说明 | -|------|------| -| JSON 文件 | 直接作为 `agent_result` 的内容合并 | -| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 | -| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON | +| 方式 | 说明 | +| --------- | ----------------------------------------------- | +| JSON 文件 | 直接作为 `agent_result` 的内容合并 | +| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 | +| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON | **四项全部就绪后才可开始。** @@ -90,7 +96,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 @@ -121,42 +127,45 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \ #### 必要字段 -| 字段 | 类型 | 说明 | -|------|------|------| +| 字段 | 类型 | 说明 | +| --------------- | ------------- | ------------------------------------------- | | `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 | -| `agent_result` | object | 实验结果数据,任意 JSON 对象 | +| `agent_result` | object | 实验结果数据,任意 JSON 对象 | #### agent_result 内容格式 `agent_result` 接受**任意 JSON 对象**,常见格式: **简单键值对**: + ```json { - "avg_rtt_ms": 12.5, - "status": "success", - "test_count": 5 + "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"} - ] + "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} - ] + "experiment_data": [ + { "温度": 25, "压力": 101.3, "产率": 0.85 }, + { "温度": 30, "压力": 101.3, "产率": 0.91 } + ] } ``` @@ -178,22 +187,22 @@ python scripts/prepare_agent_result.py \ [--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`) | +| 参数 | 必选 | 说明 | +| ----------------- | ---------- | ----------------------------------------------- | +| `--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) | 字段直接合并到 `agent_result` 顶层 | +| `.json`(list/other) | 以文件名为 key 放入 `agent_result` | +| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 | 多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。 @@ -210,7 +219,7 @@ python scripts/prepare_agent_result.py \ --notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \ --files results.json \ --auth YTFmZDlkNGUt... \ - --base https://uni-lab.test.bohrium.com \ + --base https://leap-lab.test.bohrium.com \ --submit ``` @@ -272,4 +281,4 @@ Task Progress: ### Q: 认证方式是 Lab 还是 Api? -本指南统一使用 `Authorization: Lab ` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 +本指南统一使用 `Authorization: Lab ` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 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/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index fc8a5ccf..0a59a2e9 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.19 + version: 0.11.1 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 91e07b24..f54f1eb7 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.19" + version: "0.11.1" source: path: ../.. diff --git a/setup.py b/setup.py index 7ca06f2e..4053388e 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.10.19', + version='0.11.1', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index eebdd757..fee46bd8 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.19" +__version__ = "0.11.1" diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 6c097682..8de9a75f 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -12,6 +12,15 @@ from typing import Dict, Any, List import networkx as nx import yaml +# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符 +# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃 +if sys.platform == "win32": + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] + except (AttributeError, OSError): + pass + # 首先添加项目根目录到路径 current_dir = os.path.dirname(os.path.abspath(__file__)) unilabos_dir = os.path.dirname(os.path.dirname(current_dir)) @@ -233,7 +242,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( @@ -438,10 +447,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" @@ -553,7 +562,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 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 a4fb6433..4823a232 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1113,7 +1113,7 @@ class MessageProcessor: "task_id": task_id, "job_id": job_id, "free": free, - "need_more": need_more, + "need_more": need_more + 1, }, } @@ -1253,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) @@ -1269,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, @@ -1286,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) @@ -1369,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) @@ -1468,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": { @@ -1499,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_multiway_valve.py b/unilabos/devices/virtual/virtual_multiway_valve.py index 1512f33d..b6a95ddf 100644 --- a/unilabos/devices/virtual/virtual_multiway_valve.py +++ b/unilabos/devices/virtual/virtual_multiway_valve.py @@ -2,6 +2,8 @@ import time import logging from typing import Union, Dict, Optional +from unilabos.registry.decorators import topic_config + class VirtualMultiwayValve: """ @@ -41,13 +43,11 @@ class VirtualMultiwayValve: def target_position(self) -> int: return self._target_position - def get_current_position(self) -> int: - """获取当前阀门位置 📍""" - return self._current_position - - def get_current_port(self) -> str: - """获取当前连接的端口名称 🔌""" - return self._current_position + @property + @topic_config() + def current_port(self) -> str: + """当前连接的端口名称 🔌""" + return self.port def set_position(self, command: Union[int, str]): """ @@ -169,12 +169,14 @@ class VirtualMultiwayValve: self._status = "Idle" self._valve_state = "Closed" - close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})" + close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})" self.logger.info(close_msg) return close_msg - def get_valve_position(self) -> int: - """获取阀门位置 - 兼容性方法 📍""" + @property + @topic_config() + def valve_position(self) -> int: + """阀门位置 📍""" return self._current_position def set_valve_position(self, command: Union[int, str]): @@ -229,19 +231,16 @@ class VirtualMultiwayValve: self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...") return self.set_to_pump_position() - def get_flow_path(self) -> str: - """获取当前流路路径描述 🌊""" - current_port = self.get_current_port() + @property + @topic_config() + def flow_path(self) -> str: + """当前流路路径描述 🌊""" if self._current_position == 0: - flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})" - else: - flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})" - - # 删除debug日志:self.logger.debug(f"🌊 当前流路: {flow_path}") - return flow_path + return f"🚰 转移泵已连接 (位置 {self._current_position})" + return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})" def __str__(self): - current_port = self.get_current_port() + current_port = self.current_port status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌" return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})" @@ -253,7 +252,7 @@ if __name__ == "__main__": print("🔄 === 虚拟九通阀门测试 === ✨") print(f"🏠 初始状态: {valve}") - print(f"🌊 当前流路: {valve.get_flow_path()}") + print(f"🌊 当前流路: {valve.flow_path}") # 切换到试剂瓶1(1号位) print(f"\n🔌 切换到1号位: {valve.set_position(1)}") diff --git a/unilabos/devices/virtual/virtual_stirrer.py b/unilabos/devices/virtual/virtual_stirrer.py index 8e95617f..5bd4b9e1 100644 --- a/unilabos/devices/virtual/virtual_stirrer.py +++ b/unilabos/devices/virtual/virtual_stirrer.py @@ -3,6 +3,7 @@ import logging import time as time_module from typing import Dict, Any +from unilabos.registry.decorators import topic_config from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class VirtualStirrer: @@ -314,9 +315,11 @@ class VirtualStirrer: def min_speed(self) -> float: return self._min_speed - def get_device_info(self) -> Dict[str, Any]: - """获取设备状态信息 📊""" - info = { + @property + @topic_config() + def device_info(self) -> Dict[str, Any]: + """设备状态快照信息 📊""" + return { "device_id": self.device_id, "status": self.status, "operation_mode": self.operation_mode, @@ -325,12 +328,9 @@ class VirtualStirrer: "is_stirring": self.is_stirring, "remaining_time": self.remaining_time, "max_speed": self._max_speed, - "min_speed": self._min_speed + "min_speed": self._min_speed, } - - # self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}") - return info - + def __str__(self): status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌" return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)" \ No newline at end of file diff --git a/unilabos/devices/virtual/virtual_transferpump.py b/unilabos/devices/virtual/virtual_transferpump.py index 2d3c9d8b..f7b24f18 100644 --- a/unilabos/devices/virtual/virtual_transferpump.py +++ b/unilabos/devices/virtual/virtual_transferpump.py @@ -4,6 +4,7 @@ from enum import Enum from typing import Union, Optional import logging +from unilabos.registry.decorators import topic_config from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode @@ -385,8 +386,10 @@ class VirtualTransferPump: """获取当前体积""" return self._current_volume - def get_remaining_capacity(self) -> float: - """获取剩余容量""" + @property + @topic_config() + def remaining_capacity(self) -> float: + """剩余容量 (ml)""" return self.max_volume - self._current_volume def is_empty(self) -> bool: 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 80aba3e2..62cd2dbe 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -825,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 25a2e57f..1dffe169 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -343,6 +343,7 @@ def action( auto_prefix: bool = False, parent: bool = False, node_type: Optional["NodeType"] = None, + feedback_interval: Optional[float] = None, ): """ 动作方法装饰器 @@ -378,9 +379,16 @@ def action( """ 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 @@ -399,6 +407,8 @@ 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] 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 0fce3824..b828c6d2 100644 --- a/unilabos/registry/devices/virtual_device.yaml +++ b/unilabos/registry/devices/virtual_device.yaml @@ -3960,6 +3960,14 @@ virtual_separator: io_type: source label: bottom_phase_out side: SOUTH + - data_key: top_outlet + data_source: executor + data_type: fluid + description: 上相(轻相)液体输出口 + handler_key: topphaseout + io_type: source + label: top_phase_out + side: NORTH - data_key: mechanical_port data_source: handle data_type: mechanical diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 15b1b537..aa3db9b2 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -238,6 +238,7 @@ class Registry: "class_name": "unilabos_class", }, "always_free": True, + "feedback_interval": 300.0, }, "test_latency": test_latency_action, "auto-test_resource": test_resource_action, @@ -829,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 @@ -852,6 +854,8 @@ 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 @@ -975,10 +979,12 @@ 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 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/resources/graphio.py b/unilabos/resources/graphio.py index ffe0c58f..239e2f48 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -1034,7 +1034,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: # 如果有液体,使用液体的名称 @@ -1052,7 +1052,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, @@ -1125,7 +1125,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..72514b99 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) @@ -1825,10 +1971,15 @@ class BaseROS2DeviceNode(Node, Generic[T]): mapped_plr_resources = [] for uuid in uuids_list: + found = None for plr_resource in figured_resources: r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid) - mapped_plr_resources.append(r) - break + if r is not None: + found = r + break + if found is None: + raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源") + mapped_plr_resources.append(found) return mapped_plr_resources @@ -1921,16 +2072,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 2cac28f4..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 @@ -618,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: """ @@ -1168,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 @@ -1178,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): """ @@ -1230,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) @@ -1632,6 +1647,7 @@ class HostNode(BaseROS2DeviceNode): def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict: """ timeout_seconds: 超时时间(秒),默认3600秒 + 修改的结果无效,是只读的 """ return kwargs 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 366694be..18b5f158 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -188,7 +188,13 @@ class EnvironmentChecker: "crcmod": "crcmod-plus", } - self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"} + # 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败 + pylabrobot_url = ( + "git+https://gitee.com/xuwznln/pylabrobot.git" + if _is_chinese_locale() + else "git+https://github.com/Xuwznln/pylabrobot.git" + ) + self.special_packages = {"pylabrobot": pylabrobot_url} self.version_requirements = { "msgcenterpy": "0.1.8", 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/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}", } diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index ead5eded..17552117 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.19 + 0.11.1 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln