Compare commits

..

11 Commits

Author SHA1 Message Date
Xuwznln
0b3c0e3c29 v0.11.3 2026-05-23 23:44:45 +08:00
dependabot[bot]
6025957c95 ci(deps): bump actions/deploy-pages from 4 to 5 (#251)
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 23:40:40 +08:00
dependabot[bot]
fc9c4dd8b4 ci(deps): bump actions/configure-pages from 5 to 6 (#252)
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 23:40:26 +08:00
dependabot[bot]
62ba578276 ci(deps): bump actions/upload-pages-artifact from 4 to 5 (#260)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 23:37:19 +08:00
dependabot[bot]
832e83633b ci(deps): bump conda-incubator/setup-miniconda from 3 to 4 (#261)
Bumps [conda-incubator/setup-miniconda](https://github.com/conda-incubator/setup-miniconda) from 3 to 4.
- [Release notes](https://github.com/conda-incubator/setup-miniconda/releases)
- [Changelog](https://github.com/conda-incubator/setup-miniconda/blob/main/CHANGELOG.md)
- [Commits](https://github.com/conda-incubator/setup-miniconda/compare/v3...v4)

---
updated-dependencies:
- dependency-name: conda-incubator/setup-miniconda
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 23:36:49 +08:00
Roy
bb0c68fd18 Add PLC communication guide (#264)
* Add post process station and related resources

- Created JSON configuration for post_process_station and its child post_process_deck.
- Added YAML definitions for post_process_station, bottle carriers, bottles, and deck resources.
- Implemented Python classes for bottle carriers, bottles, decks, and warehouses to manage resources in the post process.
- Established a factory method for creating warehouses with customizable dimensions and layouts.
- Defined the structure and behavior of the post_process_deck and its associated warehouses.

* feat(post_process): add post_process_station and related warehouse functionality

- Introduced post_process_station.json to define the post-processing station structure.
- Implemented post_process_warehouse.py to create warehouse configurations with customizable layouts.
- Added warehouses.py for specific warehouse configurations (4x3x1).
- Updated post_process_station.yaml to reflect new module paths for OpcUaClient.
- Refactored bottle carriers and bottles YAML files to point to the new module paths.
- Adjusted deck.yaml to align with the new organizational structure for post_process_deck.

* Add PLC communication guide for AI4M

Add a comprehensive developer guide (docs/developer_guide/add_PLC.md) describing the PLC integration standard used by Uni-Lab for workstation devices, using the AI4M implementation as reference. Covers rationale for using OPC UA, the opcua_nodes_*.csv node-table format, communication base classes (BaseOpcUaClient / OpcUaClientWithSubscription), data types, and subscription/cache/reconnect behavior. Documents driver patterns for AI4MDevice, three handshake paradigms (pulse, parameter handshake, id-based), registry/graph configuration (YAML/JSON), debugging tips (KEPServerEX sim, standalone run), and a checklist for onboarding new PLC-controlled equipment.
2026-05-23 23:35:54 +08:00
Xuwznln
3216d8e296 fix macos x64 conda artifacts
Ensure macOS x64 jobs run on an Intel runner and pass the matrix platform through to rattler-build so package metadata matches the uploaded artifact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 21:36:37 +08:00
Xuwznln
81e9068597 support notebook id 2026-05-20 18:14:13 +08:00
Xuwznln
be5ff9bc5c new build fix 2026-05-14 19:28:05 +08:00
Xuwznln
498bcd84f8 v0.11.2
(cherry picked from commit bcb1790897)
2026-05-14 18:22:09 +08:00
Xuwznln
35199eb863 env installation fix 2026-05-14 18:18:53 +08:00
34 changed files with 1224 additions and 6620 deletions

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos name: unilabos
version: 0.11.1 version: 0.11.3
source: source:
path: ../../unilabos path: ../../unilabos
@@ -54,7 +54,7 @@ requirements:
- pymodbus - pymodbus
- matplotlib - matplotlib
- pylibftdi - pylibftdi
- uni-lab::unilabos-env ==0.11.1 - uni-lab::unilabos-env ==0.11.3
about: about:
repository: https://github.com/deepmodeling/Uni-Lab-OS repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

@@ -2,7 +2,7 @@
package: package:
name: unilabos-env name: unilabos-env
version: 0.11.1 version: 0.11.3
build: build:
noarch: generic noarch: generic

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos-full name: unilabos-full
version: 0.11.1 version: 0.11.3
build: build:
noarch: generic noarch: generic
@@ -11,7 +11,7 @@ build:
requirements: requirements:
run: run:
# Base unilabos package (includes unilabos-env) # Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.11.1 - uni-lab::unilabos ==0.11.3
# Documentation tools # Documentation tools
- sphinx - sphinx
- sphinx_rtd_theme - sphinx_rtd_theme

View File

@@ -5,9 +5,98 @@ description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses
# 添加新设备到 Uni-Lab-OS # 添加新设备到 Uni-Lab-OS
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南 本 Skill 是自包含的设备接入指南,不依赖外部文档。迁移给别人时,只复制 `.cursor/skills/add-device/SKILL.md` 即可获得核心规则、模板、验证方式和常见错误清单
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。 开始实现前,仍应搜索 `unilabos/devices/` 获取同类别已有设备的接口、参数名、状态字符串和返回值风格作为参考。
---
## 接入工作流
按下面顺序推进,并在工作中维护进度:
```text
设备接入进度:
- [ ] 1. 确定设备类别(物模型)和对外单位
- [ ] 2. 确定通信协议
- [ ] 3. 收集指令协议SDK、厂商文档、寄存器表、HTTP API、用户口述
- [ ] 4. 对齐同类设备接口(搜索 unilabos/devices/
- [ ] 5. 创建驱动 unilabos/devices/<category>/<file>.py
- [ ] 6. 验证可导入、注册表扫描、启动测试
- [ ] 7. 如需要,配置实验图文件
```
## 设备类别(物模型)
优先使用已有类别。只有确实无法归类时才使用 `custom`
| 类别 ID | 说明 | 标准属性 | 标准动作 |
|---|---|---|---|
| `temperature` | 加热、冷却、温控 | `temp`, `temp_target`, `status` | `set_temperature`, `stop` |
| `pump_and_valve` | 泵、阀门、注射器 | 见子类型表 | 见子类型表 |
| `motor` | 电机、步进马达 | `position`, `status` | `enable`, `move_position`, `move_speed`, `stop` |
| `heaterstirrer` | 加热搅拌一体机 | `temp`, `stir_speed`, `status` | `set_temperature`, `stir`, `stop` |
| `balance` | 天平、称重 | `weight`, `unit`, `status` | `tare`, `read_weight` |
| `sensor` | 传感器(液位、温度等) | `value`, `level`, `status` | `read_value`, `set_threshold` |
| `liquid_handling` | 液体处理机器人 | `status`, `deck_state` | `transfer_liquid`, `aspirate`, `dispense` |
| `robot_arm` | 机械臂 | `arm_pose`, `arm_status` | `moveit_task`, `pick_and_place` |
| `workstation` | 工作站、组合设备 | `workflow_sequence`, `material_info` | `create_order`, `scheduler_start`, `scheduler_stop` |
| `virtual` | 虚拟、模拟设备 | 按模拟的真实设备定义 | 按模拟的真实设备定义 |
| `custom` | 不属于以上类别 | 用户自定义 | 用户自定义 |
`pump_and_valve` 子类型:
| 子类型 | 最小通用属性 | 最小通用动作 | 单位约定 |
|---|---|---|---|
| 注射泵syringe pump | `status`, `valve_position`, `position` | `initialize`, `set_valve_position`, `set_position`, `pull_plunger`, `push_plunger`, `stop_operation` | 体积=mL, 速度=mL/s |
| 电磁阀solenoid valve | `status`, `valve_position` | `open`, `close`, `set_valve_position` | 无 |
| 蠕动泵peristaltic pump | `status`, `speed` | `start`, `stop`, `set_speed` | 流速=mL/min |
对外暴露的属性和动作参数必须使用用户友好的物理单位mL、ul、degC、RPM 等),硬件原始值转换放在驱动内部。
## 通信协议和指令来源
先确认通信方式,再确认具体指令协议。物模型只定义设备“应该做什么”,不会告诉你硬件“具体发什么字节/请求”。
| 协议 | 常用 config 参数 | 常用依赖 | 现有抽象 |
|---|---|---|---|
| Serial (RS232/RS485) | `port`, `baudrate`, `timeout` | `pyserial` | 直接使用 `serial.Serial` |
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
| TCP Socket | `host`, `port`, `timeout` | stdlib | 直接使用 `socket` |
| HTTP API | `url`, `token`, `timeout` | `requests` | `device_comms/rpc.py` |
| OPC UA | `url` | `opcua` | `device_comms/opcua_client/` |
| 无通信(虚拟) | 无 | 无 | 在动作中模拟行为 |
必须从以下来源之一获得指令细节:
| 来源 | 处理方式 |
|---|---|
| 现成 SDK/驱动代码 | 读取代码,提取指令逻辑,包装进 Uni-Lab-OS 类 |
| 协议文档/手册 | 解析命令、响应、校验、寄存器、错误码 |
| 用户口述 | 按描述实现指令编解码,标出不确定点 |
| 标准协议 | 使用标准实现,例如 Modbus 寄存器表、SCPI |
| 虚拟设备 | 跳过硬件通信,在动作方法中维护模拟状态 |
## 对齐已有实现(强制)
实现前必须搜索 `unilabos/devices/` 中同类别设备:
- 参数名必须与已有设备保持一致;动作方法参数名是接口契约,不要随意改成 `volume_ml``target_temp_c` 这类新名字。
- `status` 字符串值要和同类设备一致,优先使用英文稳定值,例如 `Idle``Running``Error`
- 状态属性用 `@property` + `@topic_config()` 明确声明。
- 返回值使用结构化 dict至少包含 `success`,需要给前端展示的信息放在 `message``data``error` 等字段。
## 架构选择
| 场景 | 推荐方式 |
|---|---|
| 简单设备 | 纯 Python 类 + `@device` |
| 工作站/组合设备 | `WorkstationBase` 或项目内已有工作站模式 |
| 液体处理 | `LiquidHandlerAbstract` / PyLabRobot 相关模式 |
| Modbus 设备 | 复用 `device_comms/modbus_plc/` 或项目内 Modbus 示例 |
| OPC UA 设备 | 复用 `device_comms/opcua_client/` |
| 外部独立包 | 使用 `create-device-package` skill |
--- ---
@@ -87,6 +176,29 @@ Args:
- 如果只写 `param: 参数说明``title` 会兜底为字段名,`description` 使用参数说明。 - 如果只写 `param: 参数说明``title` 会兜底为字段名,`description` 使用参数说明。
- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>``description=""`,但新设备应优先写清楚显示名和说明。 - 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>``description=""`,但新设备应优先写清楚显示名和说明。
### 特殊参数类型ResourceSlot / DeviceSlot
需要前端选择资源或设备时用特殊类型注解registry 会自动生成 `placeholder_keys`
```python
from typing import List
from unilabos.registry.placeholder_type import DeviceSlot, ResourceSlot
@action(description="转移液体")
def transfer(self, source: ResourceSlot, target: ResourceSlot, volume_ul: float) -> dict:
"""
Args:
source[源资源]: 源容器或孔位。
target[目标资源]: 目标容器或孔位。
volume_ul[体积(ul)]: 转移体积。
"""
return {"success": True}
@action(description="同步设备")
def sync_devices(self, devices: List[DeviceSlot]) -> dict:
return {"success": True, "count": len(devices)}
```
### @topic_config — 状态属性配置 ### @topic_config — 状态属性配置
```python ```python
@@ -194,3 +306,154 @@ class MyDevice:
- `post_init``@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode` - `post_init``@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
- 运行时状态存储在 `self.data` 字典中 - 运行时状态存储在 `self.data` 字典中
- 设备文件放在 `unilabos/devices/<category>/` 目录下 - 设备文件放在 `unilabos/devices/<category>/` 目录下
---
## 通信实现片段
Serial 文本指令:
```python
def _send_command(self, cmd: str) -> str:
self.ser.write(f"{cmd}\r\n".encode())
return self.ser.readline().decode().strip()
```
RS-485 响应解析要先定位帧头,不要用硬编码索引直接解析原始响应:
```python
def _normalize_response(self, raw: str, start_marker: str = "/") -> str:
pos = raw.find(start_marker)
return raw[pos:] if pos >= 0 else raw
```
自定义二进制帧:
```python
def _build_frame(self, func_code: int, data: bytes) -> bytes:
frame = bytearray([0xFE, func_code]) + bytearray(data)
checksum = sum(frame[1:]) % 256
frame.append(checksum)
return bytes(frame)
```
Modbus 寄存器映射:
```python
REGISTER_MAP = {
"temp_target": {"addr": 0x000B, "scale": 10},
}
def set_temperature(self, temp: float, **kwargs) -> bool:
reg = REGISTER_MAP["temp_target"]
value = int(float(temp) * reg["scale"]) & 0xFFFF
self.client.write_register(reg["addr"], value, slave=self.slave_id)
self.data["temp_target"] = temp
return True
```
HTTP API 映射:
```python
API_MAP = {
"set_temperature": {
"method": "POST",
"endpoint": "/api/temperature",
"body_key": "target",
},
}
```
SDK 封装:
```python
from my_device_sdk import DeviceController
class MyDevice:
def __init__(self, device_id=None, config=None, **kwargs):
self.config = config or {}
self.controller = DeviceController(port=self.config.get("port", "COM1"))
```
---
## 验证
无需手写注册表 YAML。`@device` 装饰器 + AST 扫描会在启动或检查时生成注册表条目。
```bash
# 1. 模块可导入
python -c "from unilabos.devices.<category>.<file> import <ClassName>"
# 2. 启动测试
unilab -g <graph>.json
# 3. 仅检查注册表
unilab --check_mode --skip_env_check
```
仅在旧代码无 `@device`、需要覆盖特殊字段、或做 `--complete_registry` 旧设备补全时,才考虑 YAML。新设备默认不要手写 YAML。
## 图文件节点模板
实验图 JSON 中的 `class` 对应 `@device(id=...)``config` 会传入 `__init__``config` 字典:
```json
{
"id": "my_device_1",
"name": "我的设备",
"children": [],
"parent": null,
"type": "device",
"class": "my_device",
"position": {"x": 0, "y": 0, "z": 0},
"config": {
"port": "/dev/ttyUSB0",
"baudrate": 9600
},
"data": {}
}
```
工作站需要同时配置 `deck``children`
```json
{
"nodes": [
{
"id": "my_station",
"type": "device",
"class": "my_workstation",
"children": ["my_deck"],
"config": {},
"deck": {
"data": {
"_resource_child_name": "my_deck",
"_resource_type": "unilabos.resources.my_module:MyDeck"
}
}
},
{
"id": "my_deck",
"type": "deck",
"class": "MyDeckClass",
"parent": "my_station",
"config": {"type": "MyDeckClass", "setup": true}
}
]
}
```
---
## 常见错误清单
- 缺少 `@device`:设备不会被 AST 扫描发现。
- 只有 `@property` 没有 `@topic_config()`:属性不会稳定广播到 `status_types`
- `post_init` 没有 `@not_action`:会被误暴露为动作。
- `self.data = {}`:空字典会导致属性读取和 schema 初始数据不稳定,必须预填充每个状态键。
- 动作参数重命名:不要把同类设备已有的 `volume` 改成 `volume_ml`,参数名是接口契约。
- `status` 使用中文或临时文本:前端和工作流依赖稳定英文状态值。
- async 方法中使用 `time.sleep()`:应使用 `await self._ros_node.sleep(seconds)`
- 硬编码串口响应索引RS-485 响应前可能有噪声字节,应先定位帧头。
- 把硬件寄存器单位暴露给用户:对外使用物理单位,驱动内部做 scale 转换。

View File

@@ -10,7 +10,8 @@ description: Operate Virtual Workbench via REST API — prepare materials, move
- **device_id**: `virtual_workbench` - **device_id**: `virtual_workbench`
- **Python 源码**: `unilabos/devices/virtual/workbench.py` - **Python 源码**: `unilabos/devices/virtual/workbench.py`
- **设备类**: `VirtualWorkbench` - **设备类**: `VirtualWorkbench`
- **动作**: 6`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm` - **当前纳入动作**: 5 个`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`
- **暂跳过动作**: `manual_confirm`、扣电测试 `test`(需要启用时先从最新注册表重新提取 schema
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s独占锁和 3 个加热台(每次加热 60s可并行 - **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s独占锁和 3 个加热台(每次加热 60s可并行
### 典型工作流程 ### 典型工作流程
@@ -151,7 +152,8 @@ curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
| `auto-start_heating` | `UniLabJsonCommand` | | `auto-start_heating` | `UniLabJsonCommand` |
| `auto-move_to_output` | `UniLabJsonCommand` | | `auto-move_to_output` | `UniLabJsonCommand` |
| `transfer` | `UniLabJsonCommandAsync` | | `transfer` | `UniLabJsonCommandAsync` |
| `manual_confirm` | `UniLabJsonCommand` |
> `manual_confirm` 和扣电测试 `test` 当前不纳入本 skill 的推荐操作范围;不要基于历史 JSON 直接调用,需先重新生成并校验 schema。
### 10. 查询任务状态 ### 10. 查询任务状态
@@ -225,11 +227,9 @@ curl -s -X PUT "$BASE/api/v1/edge/material/node" \
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 | | `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 | | `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 | | `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 字段**,参数为纯数值/整数。 > `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
> `manual_confirm` 先跳过,不维护其 Slot 字段表。
--- ---
@@ -270,3 +270,13 @@ prepare_materials (count=5)
``` ```
创建节点时,`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` 创建节点时,`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`
`start_heating` 完成后还需要继续连接到 `move_to_output`,否则加热完成的物料不会移出加热台:
| source action | source handle | target action | target handle | 传递参数 |
| ------------- | ------------- | ------------- | ------------- | -------- |
| `auto-prepare_materials` | `channel_N` | `auto-move_to_heating_station` | `material_input` | `material_number` |
| `auto-move_to_heating_station` | `heating_station_output` | `auto-start_heating` | `station_id_input` | `station_id` |
| `auto-move_to_heating_station` | `material_number_output` | `auto-start_heating` | `material_number_input` | `material_number` |
| `auto-start_heating` | `heating_done_station` | `auto-move_to_output` | `output_station_input` | `station_id` |
| `auto-start_heating` | `heating_done_material` | `auto-move_to_output` | `output_material_input` | `material_number` |

View File

@@ -1,6 +1,8 @@
# Action Index — virtual_workbench # Action Index — virtual_workbench
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json` 当前纳入 5 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
暂跳过:`manual_confirm`、扣电测试 `test`。这两个动作需要启用时,先从最新 `req_device_registry_upload.json` 重新提取 schema 并校验参数。
--- ---
@@ -60,17 +62,18 @@
--- ---
## 人工确认 ## 暂跳过动作
### `manual_confirm` ### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文) 创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)。当前先不纳入推荐操作范围。
- **action_type**: `UniLabJsonCommand` - **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) - **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids` - **状态**: 暂跳过。源码参数已包含扣电测试相关字段,历史 JSON 可能过期;需要启用时重新提取 schema。
- **占位符字段**:
- `resource`**ResourceSlot**,物料数组 ### `test`
- `target_device`**DeviceSlot**,目标设备路径
- `mount_resource`**ResourceSlot**,目标孔位数组 启动扣电测试。当前先不纳入本 skill。
- `assignee_user_ids``unilabos_manual_confirm` 类型
- **状态**: 暂跳过。需要启用时从注册表生成 `actions/test.json` 后再补充索引。

View File

@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup Miniforge - name: Setup Miniforge
uses: conda-incubator/setup-miniconda@v3 uses: conda-incubator/setup-miniconda@v4
with: with:
miniforge-version: latest miniforge-version: latest
use-mamba: true use-mamba: true

View File

@@ -43,7 +43,7 @@ jobs:
platform: linux-64 platform: linux-64
env_file: unilabos-linux-64.yaml env_file: unilabos-linux-64.yaml
script_ext: sh script_ext: sh
- os: macos-15 # Intel (via Rosetta) - os: macos-15-intel # Intel x86_64
platform: osx-64 platform: osx-64
env_file: unilabos-osx-64.yaml env_file: unilabos-osx-64.yaml
script_ext: sh script_ext: sh
@@ -86,7 +86,7 @@ jobs:
- name: Setup Miniforge (with mamba) - name: Setup Miniforge (with mamba)
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
uses: conda-incubator/setup-miniconda@v3 uses: conda-incubator/setup-miniconda@v4
with: with:
miniforge-version: latest miniforge-version: latest
use-mamba: true use-mamba: true

View File

@@ -51,7 +51,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup Miniforge (with mamba) - name: Setup Miniforge (with mamba)
uses: conda-incubator/setup-miniconda@v3 uses: conda-incubator/setup-miniconda@v4
with: with:
miniforge-version: latest miniforge-version: latest
use-mamba: true use-mamba: true
@@ -84,7 +84,7 @@ jobs:
- name: Setup Pages - name: Setup Pages
id: pages id: pages
uses: actions/configure-pages@v5 uses: actions/configure-pages@v6
if: | if: |
github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'main' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
@@ -105,7 +105,7 @@ jobs:
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing" test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-pages-artifact@v4 uses: actions/upload-pages-artifact@v5
if: | if: |
github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'main' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
@@ -125,4 +125,4 @@ jobs:
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v4 uses: actions/deploy-pages@v5

View File

@@ -63,7 +63,7 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
platform: linux-64 platform: linux-64
env_file: unilabos-linux-64.yaml env_file: unilabos-linux-64.yaml
- os: macos-15 # Intel (via Rosetta) - os: macos-15-intel # Intel x86_64
platform: osx-64 platform: osx-64
env_file: unilabos-osx-64.yaml env_file: unilabos-osx-64.yaml
- os: macos-latest # ARM64 - os: macos-latest # ARM64
@@ -101,10 +101,11 @@ jobs:
- name: Setup Miniforge - name: Setup Miniforge
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
uses: conda-incubator/setup-miniconda@v3 uses: conda-incubator/setup-miniconda@v4
with: with:
miniforge-version: latest miniforge-version: latest
use-mamba: true use-mamba: true
python-version: '3.11.14'
channels: conda-forge,robostack-staging channels: conda-forge,robostack-staging
channel-priority: strict channel-priority: strict
activate-environment: build-env activate-environment: build-env
@@ -114,24 +115,22 @@ jobs:
- name: Install rattler-build and anaconda-client - name: Install rattler-build and anaconda-client
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y
- name: Show environment info - name: Show environment info
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
conda info conda info
conda list | grep -E "(rattler-build|anaconda-client)" conda list -n build-env | grep -E "(rattler-build|anaconda-client)"
conda run -n build-env rattler-build --version
conda run -n build-env anaconda --version
echo "Platform: ${{ matrix.platform }}" echo "Platform: ${{ matrix.platform }}"
echo "OS: ${{ matrix.os }}" echo "OS: ${{ matrix.os }}"
- name: Build conda package - name: Build conda package
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
if [[ "${{ matrix.platform }}" == "osx-arm64" ]]; then conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml --target-platform ${{ matrix.platform }} -c robostack -c robostack-staging -c conda-forge
rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge
else
rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge
fi
- name: List built packages - name: List built packages
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
@@ -171,5 +170,5 @@ jobs:
run: | run: |
for package in $(find ./output -name "*.conda"); do for package in $(find ./output -name "*.conda"); do
echo "Uploading $package to unilab organization..." echo "Uploading $package to unilab organization..."
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done done

View File

@@ -59,7 +59,7 @@ jobs:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
platform: linux-64 platform: linux-64
- os: macos-15 # Intel (via Rosetta) - os: macos-15-intel # Intel x86_64
platform: osx-64 platform: osx-64
- os: macos-latest # ARM64 - os: macos-latest # ARM64
platform: osx-arm64 platform: osx-arm64
@@ -94,10 +94,11 @@ jobs:
- name: Setup Miniforge - name: Setup Miniforge
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
uses: conda-incubator/setup-miniconda@v3 uses: conda-incubator/setup-miniconda@v4
with: with:
miniforge-version: latest miniforge-version: latest
use-mamba: true use-mamba: true
python-version: '3.11.14'
channels: conda-forge,robostack-staging,uni-lab channels: conda-forge,robostack-staging,uni-lab
channel-priority: strict channel-priority: strict
activate-environment: build-env activate-environment: build-env
@@ -107,13 +108,15 @@ jobs:
- name: Install rattler-build and anaconda-client - name: Install rattler-build and anaconda-client
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y
- name: Show environment info - name: Show environment info
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
conda info conda info
conda list | grep -E "(rattler-build|anaconda-client)" conda list -n build-env | grep -E "(rattler-build|anaconda-client)"
conda run -n build-env rattler-build --version
conda run -n build-env anaconda --version
echo "Platform: ${{ matrix.platform }}" echo "Platform: ${{ matrix.platform }}"
echo "OS: ${{ matrix.os }}" echo "OS: ${{ matrix.os }}"
echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}"
@@ -128,7 +131,7 @@ jobs:
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
echo "Building unilabos-env (conda environment dependencies)..." echo "Building unilabos-env (conda environment dependencies)..."
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge conda run -n build-env rattler-build build -r .conda/environment/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge
- name: Upload unilabos-env to Anaconda.org (if enabled) - name: Upload unilabos-env to Anaconda.org (if enabled)
if: | if: |
@@ -140,7 +143,7 @@ jobs:
run: | run: |
echo "Uploading unilabos-env to uni-lab organization..." echo "Uploading unilabos-env to uni-lab organization..."
for package in $(find ./output -name "unilabos-env*.conda"); do for package in $(find ./output -name "unilabos-env*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done done
- name: Build unilabos (with pip package) - name: Build unilabos (with pip package)
@@ -148,7 +151,7 @@ jobs:
run: | run: |
echo "Building unilabos package..." echo "Building unilabos package..."
# 如果已上传到 Anaconda从 uni-lab channel 获取 unilabos-env否则从本地 output 获取 # 如果已上传到 Anaconda从 uni-lab channel 获取 unilabos-env否则从本地 output 获取
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output conda run -n build-env rattler-build build -r .conda/base/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge --channel ./output
- name: Upload unilabos to Anaconda.org (if enabled) - name: Upload unilabos to Anaconda.org (if enabled)
if: | if: |
@@ -160,7 +163,7 @@ jobs:
run: | run: |
echo "Uploading unilabos to uni-lab organization..." echo "Uploading unilabos to uni-lab organization..."
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done done
- name: Build unilabos-full - Only when explicitly requested - name: Build unilabos-full - Only when explicitly requested
@@ -170,7 +173,7 @@ jobs:
github.event.inputs.build_full == 'true' github.event.inputs.build_full == 'true'
run: | run: |
echo "Building unilabos-full package on ${{ matrix.platform }}..." echo "Building unilabos-full package on ${{ matrix.platform }}..."
rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output conda run -n build-env rattler-build build -r .conda/full/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge --channel ./output
- name: Upload unilabos-full to Anaconda.org (if enabled) - name: Upload unilabos-full to Anaconda.org (if enabled)
if: | if: |
@@ -181,7 +184,7 @@ jobs:
run: | run: |
echo "Uploading unilabos-full to uni-lab organization..." echo "Uploading unilabos-full to uni-lab organization..."
for package in $(find ./output -name "unilabos-full*.conda"); do for package in $(find ./output -name "unilabos-full*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done done
- name: List built packages - name: List built packages

View File

@@ -0,0 +1,611 @@
# PLC 通信标准与设备驱动编写指南(基于 AI4M 工站)
> 本文档以 `unilabos/devices/workstation/AI4M`(水凝胶检测工站)为参考实现,
> 介绍如何将 PLC 控制的实验设备接入 Uni-Lab-OS包含通信协议选型、节点表标准、
> 通信基类、设备驱动、Registry 配置以及调试方法。
>
> 阅读对象:负责现场调试与设备接入的同学。
---
## 0. 总览:一台 PLC 设备从硬件到云端的链路
```
PLC西门子 / 倍福 / 三菱 / 汇川 / 国产 PLC ...
│ 各家 PLC 私有协议S7 / Modbus / EtherCAT ...
┌──────────┴──────────┐
│ OPC UA Server │ ← 统一在 PLC 侧或独立网关上配置
│ (内置或 KEPServer
└──────────┬──────────┘
│ OPC UA over TCP标准协议
┌──────────┴──────────┐
│ Uni-Lab 设备驱动 │ ← 本教程主体
│ AI4MDevice │
│ ├─ base_opcua_client.py 通信基类
│ ├─ opcua_nodes_*.csv 节点表(标准)
│ └─ AI4M.py 动作函数
└──────────┬──────────┘
│ ROS2 Action / 云端 HTTP
实验记录本 / 云端调度
```
**统一约定**:所有 PLC 设备**只暴露 OPC UA 接口**给 Uni-LabPC 端不直接处理 S7 / Modbus 等底层协议。
这是 Uni-Lab 在工站类设备上的 PLC 通信标准。
---
## 1. 为什么选 OPC UA 作为标准?
| 维度 | 自研 TCP/串口协议 | Modbus | **OPC UA** |
|---|---|---|---|
| 厂家无关 | ✗ | 部分 | **✓** |
| 自带类型系统 | ✗ | ✗(裸寄存器) | **Boolean/Int16/Float...** |
| 命名空间 / 节点树 | ✗ | ✗(地址=魔数) | **✓(带名字、可分组)** |
| 订阅推送 | ✗ | ✗ | **DataChange Notification** |
| 鉴权 / 加密 | 自己造 | ✗ | **✓** |
| 与 PLC 工程师沟通成本 | 高 | 中 | **低(按变量名沟通)** |
实际接入时PLC 工程师只需要在 PLC 侧把约定的"上位通讯变量"暴露到 OPC UA Server
我们在 PC 侧就能用 `节点名 + 数据类型` 直接读写,不用管底层是 S7 还是 Modbus。
---
## 2. 节点表标准:`opcua_nodes_xxx.csv`
PLC 侧暴露的所有变量统一**用一张 CSV 表**描述,这是 PC 端和 PLC 端**唯一的接口契约**。
位置示例:`unilabos/devices/workstation/AI4M/opcua_nodes_AI4M.csv`
### 2.1 列定义
| 列名 | 是否必填 | 说明 |
|---|---|---|
| `Name` | ✅ | 节点名PLC 工程师在 PLC 项目中真实使用的变量名,通常是中文/原始名) |
| `EnglishName` | 推荐 | 英文别名,**PC 端代码全部用这个名字**调用 |
| `NodeType` | ✅ | `VARIABLE`(变量)或 `METHOD`方法AI4M 全部用变量 |
| `DataType` | ✅ | `BOOLEAN` / `INT16` / `INT32` / `FLOAT` / `DOUBLE` / `STRING` ... |
| `NodeLanguage` | 推荐 | `Chinese` / `English`,配合 `EnglishName` 做映射 |
| `NodeId` | ✅ | OPC UA 标准 NodeId格式 `ns=<namespace>;s=<string>``ns=<n>;i=<int>` |
### 2.2 真实样例(节选自 `opcua_nodes_AI4M.csv`
| Name | EnglishName | NodeType | DataType | NodeLanguage | NodeId |
|---|---|---|---|---|---|
| 机器人空闲 | `robot_ready` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|机器人空闲` |
| 机器人取烧杯编号 | `robot_pick_beaker_id` | VARIABLE | INT16 | Chinese | `ns=4;s=上位通讯变量\|机器人取烧杯编号` |
| 检测1请求参数 | `station_1_request_params` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|检测1请求参数` |
| 检测1工艺完成 | `station_1_process_complete` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|检测1工艺完成` |
| 磁力搅拌参数设置_C[0].搅拌速度 | `mag_stirrer_c0_stir_speed` | VARIABLE | INT16 | Chinese | `ns=4;s=上位通讯变量\|磁力搅拌参数设置_C[0].搅拌速度` |
| 报警复位 | `alarm_reset` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|报警复位` |
### 2.3 设计规范(必读)
1. **命名按"角色-编号-属性"分层**,便于代码批量寻址:
- `mag_stirrer_c{0..4}_stir_speed`(搅拌仪 0~4 的搅拌速度)
- `station_{1..3}_process_complete`(检测站 1~3 的完成信号)
- `robot_rack_pick_beaker_{1..5}_complete`(取烧杯 1~5 的完成信号)
这样在驱动里可以直接 `f"mag_stirrer_c{idx}_stir_speed"` 拼出节点名。
2. **数据类型与 PLC 侧严格一致**
- `BOOL``BOOLEAN``INT/WORD``INT16/UINT16``DINT``INT32``REAL``FLOAT`
- 类型不一致会触发 `BadTypeMismatch`,写入失败。
3. **NodeId 必须从 PLC 工程或 OPC UA Server 中导出**,不要自己拼。
常见格式:
- 西门子 1500`ns=4;s=上位通讯变量|<变量名>`
- 倍福 TwinCAT`ns=4;s=PLC1.MAIN.<变量名>`
- KEPServerEX`ns=2;s=Channel1.Device1.<Tag>`
4. **每个工站一个独立 CSV**,不要共用。
AI4M 中真机用 `opcua_nodes_AI4M.csv`,仿真用 `opcua_nodes_AI4M_sim.csv`
---
## 3. 通信基类架构
文件:`unilabos/devices/workstation/AI4M/base_opcua_client.py`
整个通信层分两层:
```
BaseOpcUaClient # 最小可用:连接 + 节点注册 + 读写 + 方法调用
│ 继承
OpcUaClientWithSubscription # 生产可用:+ 订阅推送 + 缓存 + 自动重连
│ 继承
AI4MDevice # 业务驱动:在它之上写设备动作函数
```
### 3.1 `BaseOpcUaClient` 核心能力
```python
class BaseOpcUaClient(UniversalDriver):
client: Optional[Client] = None
_node_registry: Dict[str, OpcUaNodeBase] = {} # name -> Variable/Method
_name_mapping: Dict[str, str] = {} # 英文名 -> 中文名
_reverse_mapping: Dict[str, str] = {} # 中文名 -> 英文名
_found_node_objects: Dict[str, Any] = {} # 缓存 ua.Node 用于订阅
@classmethod
def load_csv(cls, file_path) -> Tuple[List[OpcUaNode], dict, dict]: ...
def register_node_list(self, node_list) -> "BaseOpcUaClient": ...
def use_node(self, name) -> OpcUaNodeBase: ...
def read_node(self, node_name: str) -> str: ... # 返回 JSON
def write_node(self, json_input: str) -> str: ...
def call_method(self, node_name, *args) -> Tuple[Any, bool]: ...
```
它做的事情可以归纳为四步:
1. **`load_csv`**:读取节点表,建立 `Name ↔ EnglishName` 双向映射。
2. **`register_node_list`**:把节点登记进 `_variables_to_find` 待查找列表。
3. **`_connect``_find_nodes`**:连上 OPC UA 后,按 `NodeId` 把每个节点解析成 `Variable` / `Method` 对象,放进 `_node_registry`
4. **`use_node(name)`**:业务代码取节点的唯一入口,**支持中英文混用**,找不到会自动重试一次。
### 3.2 `OpcUaClientWithSubscription` 增强能力
`BaseOpcUaClient` 基础上提供三个生产环境必备的能力:
#### a) 订阅缓存(高频读零开销)
```python
def _setup_subscriptions(self):
self._subscription = self.client.create_subscription(
self._subscription_interval, # 默认 500ms
SubscriptionHandler(self),
)
for node_name, node in self._node_registry.items():
if node.type == NodeType.VARIABLE and node.node_id:
handle = self._subscription.subscribe_data_change(ua_node)
self._subscription_handles[node_name] = handle
```
当 PLC 侧变量变化时,`datachange_notification` 回调会把新值写进 `self._node_values[name]`
后续 `get_node_value` 优先读缓存——**业务代码可以放心地写 `while not self.get_node_value(...): time.sleep(1)` 而不用担心 OPC UA 频繁请求**。
#### b) 智能缓存的 `get_node_value`
```python
def get_node_value(self, name, use_cache=True, force_read=False):
# 1. 中英文名归一化
chinese_name = self._name_mapping.get(name, name)
# 2. force_read=True 强制透传到 OPC UA Server
if force_read: ...
# 3. 命中订阅推送 → 直接返回缓存
# 4. 命中按需读 + 未过期cache_timeout=5s→ 返回缓存
# 5. 否则发起 read 并更新缓存
```
#### c) 连接监控 + 自动重连
后台线程每 30s 调一次 `client.get_namespace_array()` 探活,断线则自动 `disconnect → connect → 重新订阅`,最多重试 5 次。
### 3.3 数据类型 / 节点类型
`unilabos/device_comms/opcua_client/node/uniopcua.py`
```python
class DataType(Enum):
BOOLEAN = VariantType.Boolean
INT16 = VariantType.Int16
INT32 = VariantType.Int32
FLOAT = VariantType.Float
STRING = VariantType.String
# ...
class NodeType(Enum):
VARIABLE = NodeClass.Variable
METHOD = NodeClass.Method
OBJECT = NodeClass.Object
```
`Variable.write()` 内部会按 `DataType` 做强制类型转换,
所以 CSV 里的 `DataType` 列就是"PC 端转换写入值的类型说明书"。
---
## 4. 编写设备驱动:以 `AI4MDevice` 为例
文件:`unilabos/devices/workstation/AI4M/AI4M.py`
### 4.1 继承通信基类,最小骨架
```python
from typing import Optional
from unilabos.devices.workstation.AI4M.base_opcua_client import OpcUaClientWithSubscription
class AI4MDevice(OpcUaClientWithSubscription):
def __init__(
self,
url: str, # opc.tcp://192.168.1.10:4840
deck: Optional[AI4M_deck] = None, # 物料台面(资源树)
csv_path: str = None, # 节点表 CSV
username: str = None,
password: str = None,
use_subscription: bool = True,
cache_timeout: float = 5.0,
subscription_interval: int = 500,
*args, **kwargs,
):
super().__init__(
url=url, username=username, password=password,
use_subscription=use_subscription,
cache_timeout=cache_timeout,
subscription_interval=subscription_interval,
*args, **kwargs,
)
# 物料台面初始化(见教程 4. 物料系统)
self.deck = deck or AI4M_deck(setup=True)
self._robot_lock = threading.Lock()
# 关键:加载节点表
if csv_path:
self.load_nodes_from_csv(csv_path)
```
`load_nodes_from_csv` 会一次性完成:解析 CSV → 注册节点 → 解析 NodeId → 建立订阅,
**之后整个驱动都通过 `self.get_node_value(name)` / `self.set_node_value(name, value)` 操作 PLC**
### 4.2 PLC 通信的核心模式握手协议Handshake
PLC 编程的本质是"扫描周期 + 状态机"PC 端**绝对不能用 fire-and-forget 的方式发指令**。
和 PLC 配合的标准模式是 **"PC 写指令 → PC 等待 PLC 回执 → PC 复位指令"**。
AI4M 中所有 `trigger_*` 函数都遵循以下三种握手范式之一:
#### 范式 A脉冲触发 + 完成信号(最常用)
```python
def trigger_init(self) -> dict:
# ① 复位上一轮残留
self.set_node_value("alarm_reset", True); time.sleep(1.0)
self.set_node_value("alarm_reset", False)
self.set_node_value("manual_auto_switch", False)
# ② 等待 PLC 退出自动模式
while self.get_node_value("auto_mode"):
time.sleep(1.0)
# ③ 发起初始化脉冲True → False
self.set_node_value("initialize", True); time.sleep(1.0)
self.set_node_value("initialize", False)
# ④ 等待 PLC 给出完成信号
while not self.get_node_value("init finished"):
time.sleep(1.0)
return {"message": "设备初始化完成"}
```
要点:
- **"PC 写一个 BOOL 拉高再拉低"** 模拟脉冲PLC 用上升沿触发动作。
- **`get_node_value` 要在 while 循环里轮询**,配合订阅缓存基本无压力。
- **每个动作必须有"开始"和"完成"两个独立的 BOOL 节点**,不能复用。
#### 范式 B参数下发 + 请求/已执行/完成 三步握手(带数据的工艺)
```python
def trigger_station_process(self, station_id: int, mag_stir_speed: int, ...):
request_node = f"station_{station_id}_request_params"
params_received_node = f"station_{station_id}_params_received"
start_node = f"station_{station_id}_start"
complete_node = f"station_{station_id}_process_complete"
# ① PC 复位三个状态位(避免上一轮影响)
self._reset_station_process_flags(station_id)
# ② 等 PLC 主动请求参数PLC 准备好了才接收)
while not self.get_node_value(request_node):
time.sleep(1.0)
# ③ PC 下发参数注意PLC 内部数组是 0-basedPC 暴露给用户是 1-based
station_idx = station_id - 1
self.set_node_value(f"mag_stirrer_c{station_idx}_stir_speed", mag_stir_speed)
self.set_node_value(f"mag_stirrer_c{station_idx}_heat_temp", mag_stir_heat_temp)
self.set_node_value(f"mag_stirrer_c{station_idx}_time_set", mag_stir_time_set)
self.set_node_value(f"syringe_pump_{station_idx}_abs_position_set", syringe_pump_abs_pos)
# ④ PC 通知 PLC "参数已就绪",等 PLC 回复"已执行"
self.set_node_value(start_node, True)
while not self.get_node_value(params_received_node):
time.sleep(1.0)
# ⑤ 等 PLC 完成整个工艺
while not self.get_node_value(complete_node):
time.sleep(5.0)
self.set_node_value(start_node, False) # 复位,方便下一轮
return {"station_id": station_id, "message": "..."}
```
四个状态位的语义:
| 信号 | 方向 | 含义 |
|---|---|---|
| `station_X_request_params` | **PLC → PC** | "我准备好了,把参数给我" |
| `station_X_start` | **PC → PLC** | "参数我已经写好了,开干" |
| `station_X_params_received` | **PLC → PC** | "参数我已经吃下了" |
| `station_X_process_complete` | **PLC → PC** | "工艺已经做完" |
**这是 PLC 通信教科书级别的标准范式**,所有带数据下发的动作都建议照抄。
#### 范式 C编号下发 + 编号对应的完成信号(多目标互锁)
```python
def trigger_robot_pick_beaker(self, pick_beaker_id: int, place_station_id: int = None, ...):
# ① 等机器人空闲(互锁)
while not self.get_node_value("robot_ready"):
time.sleep(1.0)
# ② 阶段一:下发"取哪一杯"编号 + 等"取这一杯完成"
pick_complete_node = f"robot_rack_pick_beaker_{pick_beaker_id}_complete"
self.set_node_value("robot_pick_beaker_id", pick_beaker_id)
while not self.get_node_value(pick_complete_node):
time.sleep(1.0)
# ③ 阶段二:下发"放到哪个工站"编号 + 等"放完成"
place_complete_node = f"robot_place_station_{place_station_id}_complete"
self._reset_station_process_flags(place_station_id)
self.set_node_value("robot_place_station_id", place_station_id)
while not self.get_node_value(place_complete_node):
time.sleep(1.0)
```
要点:
- **同一个动作的多个目标用"编号变量 + 编号对应的完成信号"实现**,不要每个目标都开一个开始位。
- **配合 Python 端 `threading.Lock()` 做软互锁**,避免多个线程争抢机器人。
- **每个阶段有独立的完成信号**,串行等待,不能合并。
### 4.3 一些容易踩坑的细节
1. **节点名映射**
`set_node_value("alarm_reset", True)` 实际写入的是 CSV 中文名 `报警复位`
`get_node_value` 同理。**业务代码全部用 EnglishName**,不要直接用中文。
2. **PLC 数组索引和 PC 不一致**
AI4M 里 PC 暴露 `station_id ∈ {1, 2, 3}`,但 PLC 内部数组是 `C[0..2]`
驱动里要做 `station_idx = station_id - 1`**这种映射只在驱动层做一次**
不要让上层registry / 实验记录本)感知。
3. **订阅模式下 BOOL 节点的边沿同步**
订阅有 ~500ms 延迟。如果你刚 `set_node_value(x, True)` 就立刻 `get_node_value(x)`
读到的可能还是 `False`(订阅还没推回来)。
解决方案:**写完后用 `force_read=True` 透传一次** 或加一段 `time.sleep`
4. **永远不要忘记复位**
`start` 拉 True 后必须有地方拉回 False否则下一轮 PLC 上升沿不触发。
AI4M 在 `_reset_station_process_flags` 中统一做:
```python
def _reset_station_process_flags(self, station_id: int) -> None:
self.set_node_value(f"station_{station_id}_process_complete", False)
self.set_node_value(f"station_{station_id}_start", False)
self.set_node_value(f"station_{station_id}_params_received", False)
```
5. **耗时长的等待 sleep 加大**
工艺等待用 `time.sleep(5.0)`,机器人等待用 `time.sleep(1.0)`,初始化等待 `time.sleep(1.0)`
不要全部用 0.1s 轮询,会把日志刷爆。
---
## 5. 把驱动接到 Uni-LabRegistry + Graph
### 5.1 Registry YAML动作 schema
文件:`unilabos/registry/devices/AI4M_station.yaml`
```yaml
AI4M_station:
category: [AI4M_station]
class:
module: unilabos.devices.workstation.AI4M.AI4M:AI4MDevice # ← 入口类
type: python
action_value_mappings:
auto-trigger_init:
schema:
description: 设备初始化...
properties:
goal: { properties: {}, required: [], type: object }
result:
properties: { message: { type: string } }
required: [message]
type: object
type: object
type: UniLabJsonCommand
auto-trigger_station_process:
always_free: true
schema:
description: 执行检测工艺流程
properties:
goal:
properties:
station_id: { type: integer, description: 检测编号 1-3 }
mag_stir_stir_speed: { type: integer }
mag_stir_heat_temp: { type: integer }
mag_stir_time_set: { type: integer }
syringe_pump_abs_position_set:{ type: integer }
required: [station_id, mag_stir_stir_speed, mag_stir_heat_temp,
mag_stir_time_set, syringe_pump_abs_position_set]
type: object
result: { ... }
type: UniLabJsonCommand
init_param_schema:
config:
type: object
required: [url]
properties:
url: { type: string, description: OPC UA 服务器地址 }
csv_path: { type: string, description: 节点配置 CSV 路径 }
deck: { type: string, description: 资源树配置 }
username: { type: string }
password: { type: string }
use_subscription: { type: boolean, default: true }
cache_timeout: { type: number, default: 5.0 }
subscription_interval: { type: integer, default: 500 }
```
规则总结:
- `class.module` 指向驱动类(`module:ClassName`)。
- `action_value_mappings` 中的 key 形如 `auto-<方法名>`,对应驱动里的同名 Python 方法。
- `schema.goal` 自动转成 ROS2 Action 的 goal 消息,`schema.result` 转 result。
- `init_param_schema.config` 对应 `__init__` 的入参,**所有需要现场改的参数都要列出来**(最重要的就是 `url` 和 `csv_path`)。
- `always_free: true` 表示该动作不占用工站独占锁(多检测站可并发执行)。
### 5.2 Graph JSON实例化
文件:`unilabos/devices/workstation/AI4M/AI4M.json`
```json
{
"nodes": [
{
"id": "AI4M_station",
"name": "AI4M_station",
"type": "device",
"class": "AI4M_station",
"children": ["AI4M_deck"],
"parent": null,
"config": {
"url": "opc.tcp://192.168.1.10:4840",
"csv_path": "opcua_nodes_AI4M.csv",
"deck": {
"data": {
"_resource_child_name": "AI4M_deck",
"_resource_type": "unilabos.devices.workstation.AI4M.decks:AI4M_deck"
}
}
}
},
{
"id": "AI4M_deck",
"type": "deck",
"class": "AI4M_deck",
"parent": "AI4M_station",
"config": { "type": "AI4M_deck" }
}
]
}
```
要点:
- `class` 必须和 Registry YAML 的顶层 key 完全一致(`AI4M_station`)。
- `config` 字段**逐字传给驱动 `__init__`**,所以 Graph JSON = "现场参数表"。
- 多套相同设备时拷贝一份,把 `id` / `url` 改掉即可(参考 `AI4M002_station`)。
### 5.3 启动命令(来自 `start.md`
```cmd
# 真机
python unilabos/app/main.py -g unilabos/devices/workstation/AI4M/AI4M.json `
--ak <ak> --sk <sk> --upload_registry --addr <api_url> --disable_browser
# 仿真KEPServerEX 跑在本机 49320 端口)
python unilabos/app/main.py -g unilabos/devices/workstation/AI4M/AI4Msim.json `
--ak <ak> --sk <sk> --upload_registry --disable_browser
```
`--upload_registry` 会把 `AI4M_station.yaml` 的 schema 上传到云端,
之后实验记录本就能看到所有 `auto-*` 动作。
---
## 6. 调试方法
### 6.1 用 KEPServerEX 仿真 PLC
不带 PLC 的开发机上,可以用 KEPServerEX或 `python-opcua` 自建 server模拟。
AI4M 提供了一份仿真节点表 `opcua_nodes_AI4M_sim.csv`**只改 NodeId 不改语义**
所以驱动代码无需任何改动即可在本机调试。
### 6.2 单独跑驱动(不开 ROS
在驱动文件末尾的 `if __name__ == '__main__':` 段:
```python
if __name__ == '__main__':
A4 = AI4MDevice(
url="opc.tcp://192.168.1.10:4840",
csv_path="opcua_nodes_AI4M.csv",
)
A4.trigger_init()
print("初始化完成")
A4.trigger_robot_pick_beaker(1, 1)
```
**新动作上线前一定要在这里裸跑一遍**,确认握手时序正确,再往上接 ROS。
### 6.3 看日志判断卡在哪
`base_opcua_client.py` 的日志已经覆盖了所有关键节点:
```
✓ 客户端已连接!
✓ 找到变量节点: 'robot_ready', NodeId: ns=4;s=...
✓ 已订阅节点: robot_ready
✓ 节点查找完成:所有 142 个节点均已找到
```
如果看到 `⚠ 以下 N 个节点未找到`**99% 是 CSV 里的 NodeId 写错了**,回去对一下 PLC 工程导出的 NodeId。
### 6.4 检查节点是否能直接读写
```python
# 透传读,绕过订阅缓存
A4.get_node_value("robot_ready", force_read=True)
# 直接读 JSON 形式(适合从 HTTP/调试面板调)
A4.read_node("robot_ready")
# 写
A4.set_node_value("alarm_reset", True)
A4.write_node('{"node_name": "alarm_reset", "value": false}')
```
---
## 7. 接入新 PLC 设备的 Checklist
接到一台新工站时,按下面顺序做就能保证不漏:
- [ ] 1. 让 PLC 工程师把上位通讯变量整理到 OPC UA Server导出 NodeId 清单。
- [ ] 2. 在 `unilabos/devices/workstation/<设备名>/` 下新建目录,复制 `AI4M/base_opcua_client.py` 不动。
- [ ] 3. 整理 `opcua_nodes_<设备名>.csv`6 列填齐,并补上 `EnglishName`。
- [ ] 4. 在该目录写设备驱动 `<设备名>.py`,继承 `OpcUaClientWithSubscription`
- [ ] `__init__` 调用 `super().__init__` + `self.load_nodes_from_csv(csv_path)`。
- [ ] 每个动作函数用范式 A/B/C 写握手协议。
- [ ] 每个动作函数都返回 `dict`,至少含 `message` 字段。
- [ ] 5. 在 `unilabos/registry/devices/` 下新建 `<设备名>_station.yaml`,配置 `init_param_schema` 和 `action_value_mappings`。
- [ ] 6. 在该目录新建 `<设备名>.json`Graph填好 `url` 和 `csv_path`。
- [ ] 7. 用 `if __name__ == '__main__':` 单独跑驱动确认握手 OK。
- [ ] 8. 用 `python unilabos/app/main.py -g <Graph> --upload_registry ...` 上线,到实验记录本下发动作回归。
---
## 8. 参考实现速查
| 关注点 | 在 AI4M 中看哪里 |
|---|---|
| OPC UA 通信基类 | `base_opcua_client.py` |
| 节点定义类型系统 | `unilabos/device_comms/opcua_client/node/uniopcua.py` |
| 节点表 CSV 标准 | `opcua_nodes_AI4M.csv` |
| 设备驱动入口类 | `AI4M.py: AI4MDevice` |
| 握手范式 A脉冲+完成) | `AI4M.py: trigger_init` |
| 握手范式 B请求/参数/完成) | `AI4M.py: trigger_station_process` |
| 握手范式 C编号+完成) | `AI4M.py: trigger_robot_pick_beaker` |
| 自动模式批量参数下发 | `AI4M.py: download_auto_params` |
| Registry schema | `unilabos/registry/devices/AI4M_station.yaml` |
| Graph 实例化 | `AI4M.json` / `AI4Msim.json` |
| 启动命令 | `start.md` |

View File

@@ -1,914 +0,0 @@
# Draft: Resource And Material Sync Guidance For Sirna And Similar Bioyond Systems
Status: draft for discussion, not an implementation mandate.
This note triangulates across five source categories with different authority:
1. `docs/developer_guide/examples/workstation_architecture.md`: desired shape
and vocabulary. It is a design target, not proof that the current code has
every behavior.
2. `BioyondWorkstation`, `BioyondResourceSynchronizer`, and shared graphio code:
the current compatibility anchor. New guidance should mostly preserve this
lifecycle and extend it deliberately.
3. Existing non-Sirna Bioyond stations: practical examples of how the shared
base is used, including shortcuts that should not become policy.
4. Sirna implementation, plans, findings, and guide notes: stress-test evidence.
They expose real missing cases, but the current Sirna code is not the
architecture authority because parts were added without fully aligning to the
shared base.
5. UniLabOS resource framework behavior around PLR resources,
`ResourceTreeSet`, serialize/deserialize, and `update_resource(resources=...)`.
The short recommendation is:
Keep the shared Bioyond workstation lifecycle as the center of gravity. Evolve
the shared synchronizer with small project hooks for classification, ID-based
resolution, and non-slot material handling, rather than letting Sirna become a
parallel synchronization model. For Sirna, those hooks should resolve placement
by Bioyond IDs, distinguish physical slot labware from reagent liquid contents,
preserve Bioyond IDs in `unilabos_extra`, mutate the local PLR deck, and publish
the full deck through `update_resource(resources=[deck])`.
Do not treat every Bioyond stock row as a deck resource.
## Evidence Weighting
This task is not a Sirna implementation retrospective. It is a best-practice
alignment pass across the architecture target, the shared base class, observed
station behavior, and Sirna's newly exposed edge cases.
Use the sources this way:
- Architecture doc: ask "what shape should this system eventually have?"
- Shared Bioyond base: ask "what behavior must remain compatible today?"
- Other Bioyond stations: ask "what patterns are already working in practice?"
- Sirna current code and notes: ask "what did the base model fail to handle, and
which Sirna fixes conflict with the shared lifecycle?"
- Live API/schema evidence: ask "what is true for this deployment's material,
warehouse, and coordinate data?"
The Sirna AGENT_GUIDE is useful for finding caveats and prior investigations,
but it should not be cited as an independent source of truth when source code,
framework behavior, live API evidence, or architecture docs can answer the same
question.
## Mental Model
There are three resource worlds. Confusing them is the main source of bugs.
| World | Owner | Purpose | Recommended truth |
| --- | --- | --- | --- |
| Bioyond/LIMS | Bioyond APIs through `BioyondV1RPC` | External stock, material IDs, warehouse IDs, location IDs, inbound/outbound side effects | External material truth |
| PLR deck | Workstation driver | Runtime workstation material layout, warehouse occupancy, liquid contents | Local mutation surface |
| UniLabOS resource tree | `ResourceTreeSet` / ROS node / host resource APIs | Canonical UniLabOS/cloud representation | Framework/cloud truth |
The architecture guide describes `Deck` as the local material system and
`ResourceSynchronizer` as the optional external-system bridge
(`docs/developer_guide/examples/workstation_architecture.md:221`). The broader
framework serializes PLR objects into `ResourceTreeSet`, whose resource dicts
carry `id`, `uuid`, `parent_uuid`, `type`, `class`, `pose`, `config`, `data`,
and `extra` (`unilabos/resources/resource_tracker.py:107`).
Therefore the correct path is:
```text
Bioyond rows
-> normalized material records
-> preserve Bioyond materialTypeMode: Sample / Consumables / Reagent
-> resolve material/location/warehouse IDs
-> choose UniLabOS handling for that mode
-> mutate PLR deck
-> ResourceTreeSet.from_plr_resources([deck])
-> ROS update_resource(resources=[deck])
-> host/cloud resource-tree update
```
Avoid direct cloud JSON patches and avoid treating Bioyond records as already
being UniLabOS resource nodes.
## Target Architecture
The ideal architecture in `workstation_architecture.md` is still the right
direction:
1. `WorkstationBase` owns the local `deck`, workflow state, and hardware
interface.
2. `ResourceSynchronizer` owns external material synchronization.
3. `BioyondResourceSynchronizer` owns the Bioyond-specific use of
`BioyondV1RPC`.
4. `ROS2WorkstationNode.update_resource(resources)` owns the UniLabOS/cloud
resource-tree update.
5. Optional HTTP report handlers may trigger local deck mutation, external sync,
and cloud publication.
The documented startup sequence is:
1. Create workstation.
2. Initialize PLR deck and warehouses.
3. Create Bioyond RPC hardware interface.
4. Create resource synchronizer.
5. `sync_from_external()`.
6. Initialize ROS node and children.
7. `post_init(ros_node)`.
8. Upload `resources=[deck]`.
See `docs/developer_guide/examples/workstation_architecture.md:308`,
`docs/developer_guide/examples/workstation_architecture.md:497`, and
`docs/developer_guide/examples/workstation_architecture.md:737`.
Important caveat: that document is the hope. It is still valuable because it
names the desired responsibilities, but current Bioyond stations implement a
more limited, best-effort side-effect sync. When the doc and current code
diverge, use the doc to choose direction and the shared base code to choose the
next compatible step.
## Current Practical Behavior
The shared `BioyondWorkstation` implementation is the current behavioral
baseline. It does this today:
1. Requires a deck.
2. Reconstructs `deck.warehouses` from deck children/config when needed.
3. Creates `BioyondV1RPC`.
4. Installs `BioyondResourceSynchronizer`.
5. Immediately calls `sync_from_external()`.
6. Later, in `post_init`, publishes the whole deck with
`ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True,
resources=[self.deck])`.
Relevant code:
- `BioyondResourceSynchronizer` is defined in
`unilabos/devices/workstation/bioyond_studio/station.py:117`.
- `sync_from_external()` fetches stock `typeMode` 0, 1, and 2, then passes all
rows to `resource_bioyond_to_plr(...)`
(`unilabos/devices/workstation/bioyond_studio/station.py:147`).
- `BioyondWorkstation.__init__` installs the synchronizer and syncs immediately
(`unilabos/devices/workstation/bioyond_studio/station.py:856`).
- `post_init()` uploads the deck (`unilabos/devices/workstation/bioyond_studio/station.py:893`).
- `resource_tree_add()` performs Bioyond create/inbound side effects
(`unilabos/devices/workstation/bioyond_studio/station.py:958`).
This practical behavior works for simple physical-resource stock import, but it
does not provide full continuous two-way sync, conflict resolution, or reliable
stale-state cleanup. In particular:
- Re-fetching stock does not clearly clear stale local deck state first.
- Local-to-external update no-ops unless `unilabos_extra["update_resource_site"]`
is present.
- `process_material_change_report()` is mostly TODO-level in the base station.
- Some station-specific code bypasses the shared synchronizer with direct LIMS
calls.
- Some existing stations contain shortcuts that should not be copied, such as
duplicate initialization, stale globals, or hardcoded warehouse/axis cases.
Treat current Bioyond behavior as operationally useful and compatibility
important, not as proof that the ideal architecture is already achieved. The
goal is to tighten this base path, not replace it with a Sirna-only path.
## Recommended Shared Pipeline
For Sirna and similar Bioyond systems, the base synchronizer should evolve from
the current shared path into one shared pipeline with small project hooks:
```text
sync_from_external()
fetch stock rows from Bioyond
update RPC material cache
normalize rows into a common internal shape
preserve materialTypeMode: Sample / Consumables / Reagent
resolve warehouse/location by Bioyond IDs when available
choose handling for the row's mode
apply Sample / Consumables rows as mapped slot labware by default
apply Reagent rows as physical reagent labware or liquid content by evidence
report unknown modes or unmapped handling loudly
publish deck if deck changed and ROS node is available
```
Suggested extension points:
```python
class BioyondResourceSynchronizer(ResourceSynchronizer):
def external_material_mode(self, row: dict) -> str:
return row["materialTypeMode"] # Sample | Consumables | Reagent
def resolve_external_row(self, row: dict) -> dict:
...
def apply_external_row(self, row: dict, mode: str, resolved: dict) -> None:
...
```
The base class should continue to own:
- stock fetches for `typeMode` 0/1/2;
- material-cache refresh;
- common normalization;
- mode validation for `Sample` / `Consumables` / `Reagent`;
- publication orchestration;
- error aggregation;
- stale-state policy once agreed.
Project code should own only the parts that are truly deployment-specific:
- material type/mode to PLR class mapping;
- project-local handling inside `Sample` / `Consumables` / `Reagent`;
- project-local warehouse ID/name mapping;
- project-local coordinate conventions;
- special row handling such as Sirna reagent-as-liquid.
This keeps existing Bioyond stations close to the same lifecycle while still
absorbing the Sirna lessons that the earlier base did not model. The default
hook behavior can remain "Sample/Consumables/Reagent rows become mapped slot
labware" for simpler stations; Sirna should override Reagent handling where
evidence requires liquid-content behavior.
## Sirna Findings To Feed Back Into Shared Design
Treat Sirna as a stress test for the shared base, not as a replacement design.
It raises real questions that earlier stations did not need to answer:
- within the `Reagent` mode, some external rows may be liquid contents rather
than slot-occupying reagent labware;
- placement should be ID-first where Bioyond supplies material/location IDs;
- warehouse and axis metadata must survive serialize/deserialize;
project-specific mode handling is available.
For Sirna-like deployments, the old implicit rule "stock row equals PLR
resource" is too coarse. For simpler deployments, it can remain the default
mode-handling behavior.
### IDs Win
Placement identity should prefer:
```text
materialId
locationId
materialTypeId
warehouseId / whid
```
Display/debug fields are not identity:
```text
materialName
materialCode
locationCode
locationShowName
warehouseName / whName
```
`locationCode` such as `1-1` is not globally unique. It can exist in multiple
warehouses. Code-only resolution may be kept as a diagnostic fallback, but it
must raise on ambiguity.
The current Sirna ID-first resolver is useful source evidence for this direction
(`unilabos/devices/workstation/bioyond_studio/sirna_station/sirna_station.py:3659`).
The Sirna mega plan captures the same concern, but the implementation and live
Bioyond IDs are the stronger evidence.
### Bioyond Modes And UniLabOS Handling Are Different
Bioyond has three primary material modes in this context:
```text
Sample
Consumables
Reagent
```
Those modes should be preserved as the external taxonomy. UniLabOS still needs a
handling decision inside the mode: should the row create/place a physical PLR
resource, or update contents on an already-placed parent resource?
Physical slot resources include things like:
- tip racks;
- plates;
- cell culture plates;
- empty trough/bottle labware;
- other objects that occupy a warehouse slot.
Reagents are contents of a parent labware when the row describes a liquid rather
than a physical holder. For Sirna, Bioyond `stock_material(typeMode=2)` returns
`materialTypeMode="Reagent"` rows; those rows still need evidence-based handling
as either physical reagent labware or reagent liquid content. The finding in
`temp_benyao/sirna/_findings/2026-05-07_reagents_vs_resources.md:6` records the
bug: the generic path can fall back to `RegularContainer`, fail registry lookup,
and drop reagent rows such as `试剂槽裂解液/Betame`.
Recommended behavior:
1. Validate `materialTypeMode` as `Sample`, `Consumables`, or `Reagent`.
2. For `Sample` and `Consumables`, default to mapped slot labware: instantiate
the mapped PLR class and place it into the resolved warehouse slot.
3. For `Reagent`, decide whether the row represents physical reagent labware or
reagent liquid content from material type evidence, row shape, and live data.
4. For physical reagent labware, place the mapped PLR resource in the resolved
slot.
5. For reagent liquid content, find the parent trough/bottle and attach liquid
metadata to its tracker.
6. Preserve Bioyond IDs in `parent.unilabos_extra["reagent_bioyond_ids"]`.
7. Make liquid attachment idempotent by Bioyond material ID.
8. If the parent labware is missing or the mode/handling cannot be resolved,
defer or log with IDs; do not guess.
The existing Sirna helper `_attach_liquid_to_parent()` already follows the
idempotent metadata direction
(`unilabos/devices/workstation/bioyond_studio/sirna_station/sirna_station.py:4074`).
### The `0003` Question Must Stay Evidence-Based
Do not blindly map `materialTypeCode 0003` to `BioyondSirna_ReagentTrough`.
If live/schema evidence says a `0003` row is physical trough labware, map it to
a PLR resource class. If evidence says it is liquid content, attach it to the
parent trough. If evidence is contradictory, mark it unsupported and log the
Bioyond IDs.
Treat this as an open design decision until source/schema/live evidence settles
the row shape. The Sirna plan records the question, but it should not decide the
mapping by itself.
### Sirna Warehouse And Axis Rules Are Project-Local
Sirna warehouse layout and axis conventions must be verified from Sirna source,
Sirna schema, current deck behavior, and live/read-only Sirna APIs when
available. Do not import Peptide, reaction, dispensing, or cell station layout
truth.
For Sirna specifically, the current integrated deck display should be treated as
the good baseline. The prior col-row slot-key fix is already present in the
deck/graphio path, and any remaining x/y or y-reverse correction should be
handled through shared warehouse metadata and shared graphio/display mapping
rather than by reshaping the Sirna station display ad hoc.
### Display Geometry: Evidence Before Axis Values
Peptide resource sync should not be treated as a validated model, but its
UniLabOS display work is useful and should influence this guidance.
Confirmed Peptide behavior:
- Peptide live warehouse evidence found `自动化堆栈` with 170 locations where
`code="10-17"` appears at `x=17, y=10`
(`../Uni-Lab-OS-Peptide/temp_benyao/peptide/_findings/2026-05-13_1404_peptide_col_row_deck.md:6`).
- Peptide's current display convention is Peptide-specific evidence:
`bioyond_axis="xy_col_row"` and `bioyond_key_axis="col_row"` in the current
Peptide station implementation. Do not copy that pair into Sirna or any other
project without live warehouse evidence for that target system. With this
Peptide combination, graphio does not apply the legacy x/y swap
(`../Uni-Lab-OS-Peptide/unilabos/resources/graphio.py:868`).
- Peptide models the main automation stack as 17 visual rows by 10 visual
columns while preserving labels such as `10-17`
(`../Uni-Lab-OS-Peptide/unilabos/resources/bioyond/decks.py:308`).
- Peptide warehouses and deck child positions use `frontend_y_flip=True` /
`_frontend_y_flipped_coordinate(...)` so stored PLR coordinates compensate for
the frontend y-axis inversion
(`../Uni-Lab-OS-Peptide/unilabos/resources/bioyond/decks.py:299`;
`temp_benyao/peptide/_findings/2026-05-13_1514_frontend_y_flip_layout.md:6`).
- The older Peptide graph-layout note is stale for the Sirna discussion. The
current Sirna integrated station/deck display is a good baseline; the reusable
Peptide lesson is axis metadata and frontend y-reverse compatibility.
- Peptide tests encode the intended behavior: `10-17` lands at row 17 /
column 10, and after frontend y-flip the displayed site positions match the
expected top-to-bottom layout
(`../Uni-Lab-OS-Peptide/temp_benyao/peptide/tests/test_peptide_deck_layout.py:60`).
Guidance for Sirna and similar systems:
1. Treat `bioyond_axis` and `bioyond_key_axis` as two separate concepts:
- `bioyond_axis` describes how Bioyond numeric `x/y` map to visual axes.
- `bioyond_key_axis` describes how slot labels such as `10-17` are generated
or preserved.
2. Do not infer display orientation from label strings alone. Both `row_col`
and `col_row` can preserve the same final label text while producing
different visual layouts and graphio swap behavior.
3. Use live/read-only `code/x/y` evidence for each project and each warehouse
family before choosing axis metadata.
4. Keep visual orientation fixes separate from material identity. IDs and slot
labels determine registration; display dimensions and frontend y-flip only
determine how the deck appears.
5. Author intended display coordinates first, then convert stored y coordinates
for the active frontend convention:
- deck child stored y = `deck_height - display_y - child_height`;
- warehouse site stored y = `warehouse_height - display_y - site_height`;
- graph-level y values should be transformed only when the active frontend
convention requires it.
6. Preserve the current Sirna display as the baseline unless concrete frontend
evidence shows a y-reverse problem. Do not change station/deck graph
semantics just to match stale Peptide layout notes.
7. Add layout tests that assert:
- warehouse `num_items_x`, `num_items_y`, capacity, first key, and last key;
- representative `code/x/y` examples land on the intended site key;
- frontend y-flip produces expected displayed positions;
- generated deck children do not overlap in displayed coordinates.
8. When borrowing from Peptide, borrow the evidence pattern: live discovery,
axis/key-axis metadata, y-flip tests, and display fixtures. Do not borrow a
literal axis pair or Peptide resource-sync behavior as a validated path.
Concrete live discovery workflow:
1. Prefer the reusable read-only probe pattern before reading old findings:
```bash
python3 temp_benyao/sirna/tests/probe_readonly_storage_inventory.py \
--base-url <api_host> \
--api-key <api_key> \
--output temp_benyao/<project>/_logs/<timestamp>_readonly_storage_inventory_probe.json
```
This probe checks swagger candidates, project storage/location endpoints,
material type endpoints, `warehouse-info-by-mat-type-id`, and `stock-material`,
then writes a redacted evidence file and a merged warehouse summary.
2. For Sirna quick checks, `temp_benyao/sirna/tests/discover_sirna_warehouses.py`
is the narrower historical helper. Treat it as a template unless it has been
parameterized for the target config.
3. Query `/api/storage/location/locations-by-type?type=0&typeMode=0&materialType=0`
first for stack names, warehouse IDs, full slot lists, `code`, `x`, `y`, `z`,
and display mode. This endpoint is the topology source when available.
4. Cross-reference with `/api/lims/storage/material-types` and
`/api/lims/storage/warehouse-info-by-mat-type-id` to learn material-type
placement constraints. Use `stock-material` only for occupied-slot evidence;
it cannot reveal empty topology.
5. Infer `bioyond_key_axis` from how slot labels must be generated or preserved,
and infer `bioyond_axis` from how raw Bioyond `x/y` must map to PLR holder
indices. A label such as `10-17` alone is ambiguous; compare it to the same
record's `x/y`, and use boundary examples from non-square stacks.
6. Encode the discovered convention on the warehouse resource, not in a one-off
station branch. The metadata must serialize/deserialize with the warehouse
because graph load and cloud sync rebuild resources.
7. Add or update layout tests before changing Sirna display behavior. For Sirna,
test against the current good display first, then only change shared x/y or
y-reverse mapping if the fixture demonstrates a real mismatch.
### Deserialize Must Be Idempotent
This is confirmed framework behavior, not a possible risk. Resource publication
builds `ResourceTreeSet` from live PLR resources by calling
`resource.serialize()` and `resource.serialize_all_state()`
(`unilabos/resources/resource_tracker.py:547`). Readback/reconstruction goes
through `ResourceTreeSet.to_plr_resources()`, finds the PLR subclass, calls
`sub_cls.deserialize(...)`, then restores PLR state, UUIDs, and `unilabos_extra`
(`unilabos/resources/resource_tracker.py:637`). `ResourceTreeSet.dump()` also
serializes resource nodes while excluding `children` from each individual node
record (`unilabos/resources/resource_tracker.py:914`), so parent/child
relationships and object state must survive the framework tree shape rather than
only an in-memory PLR object graph.
Sirna resource/deck classes therefore must survive:
1. registry-time construction;
2. `Resource.deserialize()` round trips;
3. cloud-synced deck state with serialized children.
If a synced deck already has serialized `children`, default setup must not
create duplicate/stale child resources. Sirna noticed this problem, but the
reason is framework-level: cloud/resource-tree sync reconstructs PLR objects
from serialized state, so constructors and setup logic must be idempotent.
Existing Sirna deck code already acknowledges the issue: `BIOYOND_Sirna_Deck`
turns `setup=False` during deserialize when serialized `children` are present
(`unilabos/resources/bioyond/decks.py:153`). Sirna material classes also use
registry-visible `@resource(...)` decorators and tolerant constructors with
`*args, **kwargs`, defaults, and `ordering` fallbacks
(`unilabos/resources/bioyond/sirna_materials.py:14`). Treat those as required
patterns for any new synced resource class.
Guidance:
- Do not add a deck/resource class until it round-trips through
`Resource.serialize()` / `Resource.deserialize()` and
`ResourceTreeSet.from_plr_resources(...).to_plr_resources()`.
- Preserve `unilabos_uuid`, parentage, `unilabos_extra`, and liquid state across
the round trip.
- Make default setup conditional: if serialized children exist, do not recreate
default warehouses or default child resources on top of them.
- Itemized PLR subclasses must provide `ordered_items` or `ordering`; otherwise
deserialization can fail or rebuild a different structure.
## Startup And Resync Recommendation
The current Sirna code installs `SirnaResourceSynchronizer` after
`super().post_init(ros_node)` (`sirna_station.py:363`). That is useful as a
phase patch and as evidence that classification hooks are needed, but it should
not become the long-term lifecycle. Base initialization may already have run
generic stock sync and base post-init may already have published the deck before
the Sirna synchronizer is installed.
Recommended pathway:
1. Keep the `BioyondWorkstation` lifecycle as the base path.
2. Add a synchronizer factory or hook registration point before eager sync, for
example `create_resource_synchronizer()`.
3. Let Sirna provide classification/resolution hooks through that shared
synchronizer shape.
4. Make startup sync use the installed hook-aware synchronizer before the first
deck publication.
5. Make manual resync, reset resync, report-triggered resync, and future
periodic sync all call `self.resource_synchronizer.sync_from_external()`.
6. Do not instantiate fresh base synchronizers inside Sirna actions, because
that bypasses the installed project logic.
This directly addresses the overlap finding in
`temp_benyao/sirna/_findings/2026-05-12_sirna_synchronizer_overlap.md:6`.
## Material Registration From Create-Order Results
Create-order allocation registration should remain separate from stock sync, but
it should use the same classification and apply logic.
Recommended create-order pipeline:
1. Normalize allocation records into:
```python
{
"materialId": "...",
"materialCode": "...",
"materialName": "...",
"materialTypeId": "...",
"materialTypeCode": "...",
"materialTypeMode": "Sample|Consumables|Reagent",
"materialTypeName": "...",
"locationId": "...",
"locationCode": "...",
"locationShowName": "...",
}
```
2. Resolve warehouse and slot by:
```text
material-info(materialId)
-> warehouse-info-by-mat-type-id(materialTypeId) matched by locationId
-> code-only diagnostic fallback only if unambiguous
```
3. Classify as `slot_labware`, `liquid_content`, or `unsupported`.
4. Apply mutation to the PLR deck.
5. Publish the full deck once after the batch.
The current Sirna `_register_materials_to_tree()` already documents this flow
(`sirna_station.py:3659`) and should be the local reference implementation until
the shared hook is designed.
## Cloud / UniLabOS Publication Rules
Always mutate real tracked PLR resources first. Then publish through the
framework.
Correct call shape:
```python
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource,
True,
**{"resources": [self.deck]},
)
```
The real method is:
```python
async def update_resource(self, resources: List["ResourcePLR"])
```
See `unilabos/ros/nodes/base_device_node.py:727`.
Do not call:
```python
update_resource(resource_name=..., resource_data=...)
```
Do not manually serialize the deck for this path. `update_resource()` creates a
`ResourceTreeSet`, sends it to `/c2s_update_resource_tree`, and applies returned
UUID mappings. Missing root parent UUIDs are auto-mounted to the current device,
so parentage should be preserved on PLR objects before publication.
## Metadata Contract
Every Bioyond-originated slot resource should carry enough metadata for unload,
audit, and later sync:
```python
plr_resource.unilabos_extra = {
"material_bioyond_id": mat["materialId"],
"material_bioyond_code": mat["materialCode"],
"material_bioyond_name": mat["materialName"],
"material_bioyond_type_id": mat["materialTypeId"],
"material_bioyond_type_code": mat["materialTypeCode"],
"material_bioyond_type_mode": mat["materialTypeMode"],
"location_bioyond_id": mat["locationId"],
"location_code": resolved["location_code"],
"warehouse_bioyond_id": resolved["warehouse_id"],
"warehouse_bioyond_name": resolved["warehouse_name"],
"location_resolution_source": resolved["source"],
}
```
Every Bioyond-originated liquid content should preserve equivalent metadata on
the parent labware:
```python
parent.unilabos_extra.setdefault("reagent_bioyond_ids", []).append({
"material_bioyond_id": mat["materialId"],
"material_bioyond_code": mat["materialCode"],
"material_bioyond_name": mat["materialName"],
"material_bioyond_type_id": mat["materialTypeId"],
"material_bioyond_type_code": mat["materialTypeCode"],
"location_bioyond_id": mat["locationId"],
"quantity": mat.get("quantity"),
"location_resolution_source": resolved["source"],
})
```
This metadata is not decorative. It is required for correct unload, audit,
duplicate prevention, and future round-trip sync.
## Stale State And Conflict Policy
This is confirmed current behavior, not just a theoretical risk.
Current stock import places resources into empty warehouse slots and skips
occupied slots. That prevents overwrites, but it can leave stale local resources
after external moves/deletes.
Evidence:
- `BioyondResourceSynchronizer.sync_from_external()` fetches Bioyond stock and
delegates to `resource_bioyond_to_plr(...)`, but it does not compute a
before/after diff or remove local resources absent from the snapshot
(`unilabos/devices/workstation/bioyond_studio/station.py:147`).
- `resource_bioyond_to_plr(...)` only assigns when the target warehouse position
is empty or a placeholder; when a real resource already occupies the slot, it
logs "跳过放置" and leaves the existing object in place
(`unilabos/resources/graphio.py:936`).
- Bioyond outbound/removal logic exists in separate local-to-external hooks such
as `resource_tree_remove(...)`, but that is not invoked by stock refresh for
Bioyond rows that disappeared externally
(`unilabos/devices/workstation/bioyond_studio/station.py:987`).
Recommended direction:
1. Define Bioyond as the source of truth for external stock snapshots unless a
local operation is in progress.
2. Before applying a stock snapshot, compute a diff by Bioyond material ID.
3. Remove or mark local resources whose Bioyond IDs disappeared from the
snapshot, subject to workflow safety checks.
4. Move local resources whose Bioyond location ID changed.
5. Attach/detach liquid contents idempotently by Bioyond material ID.
6. Publish once after applying the batch.
7. In ambiguous or active-operation cases, log and require manual confirmation.
Until this policy exists, call the current implementation "refresh/import" rather
than "authoritative synchronization."
## External-To-Local And Local-To-External Boundaries
External-to-local:
- Bioyond stock and create-order allocation rows mutate the local PLR deck.
- Then UniLabOS publication derives resource trees from the PLR deck.
Local-to-external:
- `resource_tree_add` / update / remove may call Bioyond add, inbound, outbound,
or move APIs.
- These paths should require Bioyond IDs or explicit creation parameters.
- Movement should use `unilabos_extra["update_resource_site"]` only as an
explicit request, not as hidden ambient state.
- After successful Bioyond side effects, refresh or update the PLR deck and
publish the deck.
Avoid station-level direct LIMS calls that bypass the synchronizer unless the
action explicitly reconciles the deck afterward.
## Error Handling Rules
Fail loudly on:
- unknown material type code/name;
- unresolved warehouse ID/name;
- location ID not found in `warehouse-info-by-mat-type-id`;
- code-only location ambiguity;
- missing parent labware for liquid content;
- invalid PLR resource class or `Resource.deserialize()` failure;
- Bioyond RPC returning empty/fallback values where an ID is required.
Do not silently create `RegularContainer` to hide mapping failures. The Sirna
finding at `temp_benyao/sirna/_findings/2026-05-07_reagents_vs_resources.md:52`
calls this out as a reusable trap.
## Tests And Validation
Minimum offline tests:
1. `update_resource` is called only with `resources=[deck]`.
2. `ResourceTreeSet.from_plr_resources([deck])` preserves UUIDs, parentage,
`data`, and `unilabos_extra`.
3. Sirna allocation records resolve by `material-info` first.
4. `warehouse-info-by-mat-type-id` resolves by `locationId`.
5. Code-only fallback raises on ambiguous slots.
6. `Sample` and `Consumables` rows become slot labware.
7. Reagent content rows become parent liquid metadata.
8. Re-running registration does not duplicate liquid entries.
9. Missing parent labware is deferred/logged, not guessed.
10. Unknown material type emits Bioyond IDs.
11. Deck deserialize does not recreate duplicate default children.
12. Warehouse coordinate mapping is tested per warehouse, not globally.
13. A synced Sirna deck round-trips through PLR deserialize and
`ResourceTreeSet` conversion without duplicate default children.
14. Stock refresh with a missing Bioyond material ID proves current add/skip
behavior first, then verifies the chosen diff/delete policy once implemented.
15. Display geometry tests cover project-local live discovery fixtures,
`bioyond_axis`, `bioyond_key_axis`, representative `code/x/y` mappings,
frontend y-flip, and no-overlap deck layout.
Focused Sirna command:
```bash
pytest temp_benyao/sirna/tests/test_sirna_resource_system.py -q
```
Shared conversion command, when changing graphio or Bioyond converters:
```bash
pytest tests/resources/test_converter_bioyond.py -q
```
Live/read-only validation should capture fixtures for:
- `stock_material(typeMode=0/1/2, includeDetail=true)`;
- `material-info(materialId)`;
- `warehouse-info-by-mat-type-id(materialTypeId)`;
- create-order allocation records;
- frontend/cloud resource-tree readback after publication.
## Discrepancies To Discuss
### 1. Architecture Is Direction; Base Code Is The Compatibility Anchor
The architecture guide presents `ResourceSynchronizer` as bidirectional. Current
Bioyond code is mostly eager external import plus selected add/remove/update
side effects. It has no complete continuous conflict-resolution loop.
Sirna current code should be read in this context: it exposes missing
capabilities, but parts of it conflict with the existing base lifecycle because
it was layered on after the base sync behavior already existed.
Recommendation: keep the bidirectional architecture as the target, preserve
`BioyondWorkstation` as the implementation anchor, and introduce shared hooks
for the missing Sirna-class problems. Describe the current implementation as
best-effort refresh plus Bioyond side effects until a diff/conflict policy
exists.
### 2. The Doc Centers `Deck`; The Framework Centers `ResourceTreeSet`
The architecture guide uses PLR `Deck` as the material system. UniLabOS resource
tracking uses `ResourceTreeSet` as the canonical serialized representation.
Recommendation: phrase the model as "Deck is the local PLR mutation surface;
ResourceTreeSet is the UniLabOS/cloud representation derived from it."
### 3. Base Bioyond Sync Treats Every Stock Row As PLR Resource
`BioyondResourceSynchronizer.sync_from_external()` currently feeds typeMode
0/1/2 rows into `resource_bioyond_to_plr(...)`.
Sirna evidence shows this is wrong for reagent/content-like rows.
Recommendation: add classification hooks before conversion. Rows classified as
liquid content must not go through standalone PLR resource conversion.
### 4. Sirna Fix Exists As A Fork, Not A Shared Hook
Current `SirnaResourceSynchronizer` captures the important reagent-as-liquid
question, but it duplicates or bypasses shared sync mechanics. Startup can run
base sync before the Sirna synchronizer is installed, and manual/reset paths
have used fresh base synchronizers.
Recommendation: keep the discovered behavior where evidence supports it, but
refactor the shape. A factory/hook in the shared synchronizer is preferable to a
long-lived parallel sync class.
### 5. Sirna Create-Order Registration Is Stronger Than Stock Sync
Create-order registration now has ID-first resolution and a clearer classifier.
That is valuable evidence, not automatically the canonical sync design. The
stock sync path still resolves external liquid rows by warehouse name/code and
stores weaker metadata.
Recommendation: extract the stronger resolver/classifier ideas into shared
hooks, then converge stock sync and create-order registration on the same
functions.
### 6. Graphio Has Shared Fragility
`resource_bioyond_to_plr()` contains hardcoded warehouse/coordinate cases and a
fallback to `RegularContainer`. Hardening it affects multiple Bioyond projects.
Recommendation: after Sirna is green, discuss shared graphio hardening in a
separate change: no silent `RegularContainer`, explicit unknown-type diagnostics,
and project-specific axis metadata instead of hardcoded names.
### 7. Serialize/Deserialize Is A Gating Contract
This is not optional documentation neatness. Any new resource/deck class or sync
metadata must pass the framework serialize/deserialize path before it can be
trusted in cloud-synced operation.
Recommendation: make round-trip tests part of acceptance for resource-system
changes, especially for Sirna deck setup, warehouse metadata, `reagent_bioyond_ids`,
liquid tracker state, and itemized labware ordering.
### 8. Existing Stock Sync Is Add/Skip-Biased, Not Delete-Aware
Current `sync_from_external()` imports and places observed Bioyond resources,
but it does not delete local resources missing from the latest stock snapshot.
When a target slot is occupied by a real resource, graphio skips placement rather
than replacing it.
Recommendation: describe the current behavior as "stock refresh/import" until a
Bioyond-ID diff policy is implemented. Deletion/removal must be a deliberate
phase, with workflow-safety checks and tests, not implied by the current stock
sync name.
### 9. Peptide Display Is Useful; Peptide Sync Is Not Yet Authority
Peptide's latest display work is useful evidence for Peptide's own warehouse
axis/key-axis metadata and for the shared need to account for frontend y-axis
inversion. It does not define Sirna's axis values. Its resource sync remains
untested/problematic and should not be treated as the model for Sirna sync
behavior.
Recommendation: fold the Peptide display lesson into shared guidance as a
discovery-and-test pattern, not as a literal axis pair. Keep Sirna display
behavior grounded in Sirna live evidence and the current good deck baseline; keep
resource synchronization recommendations grounded in the Sirna ID-first
registration path and the shared Bioyond synchronizer refactor.
### 10. Some Existing Stations Should Be Examples Only, Not Templates
Non-Sirna stations show useful patterns, but also shortcuts:
- direct LIMS calls bypassing the synchronizer;
- duplicate `super().__init__()` in reaction station;
- stale/undefined `WAREHOUSE_MAPPING` references;
- RPC methods collapsing failures into empty values;
- no robust stale deck cleanup on stock refresh.
Recommendation: borrow the shared deck publication and Bioyond ID metadata
patterns, not the incidental shortcuts.
## Recommended Discussion Path
For the next implementation discussion, decide these in order:
1. What is the smallest shared-base extension that lets project hooks exist
before eager sync without breaking existing Bioyond stations?
2. Should `BioyondWorkstation` gain a synchronizer factory, a hook registry, or
a delayed eager-sync point?
3. Is `materialTypeCode 0003` in current Sirna live data physical labware,
liquid content, or both depending on row shape?
4. Where should Sirna warehouse Bioyond IDs live: station config, deck metadata,
or both?
5. Should stock refresh clear/diff deck state now, or remain append/skip until
after create-order registration is stable?
6. Should `update_resource` remain async/non-blocking for material registration,
or should selected user-facing actions fail if publication fails?
7. Which parts of the Sirna reagent-as-liquid rule are project-local, and which
should become shared Bioyond behavior after Peptide/cell validation?
8. Should graphio fallback hardening land before or after Sirna stock sync is
fully validated?
9. Should any remaining Sirna x/y or y-reverse issue be fixed in shared
graphio/display mapping after a live fixture proves it, or should the current
Sirna deck remain untouched?
## Recommended Pathway
The pragmatic path is:
1. Preserve and test the current `BioyondWorkstation` lifecycle as the shared
compatibility baseline.
2. Treat Sirna create-order registration as a local proof of the missing
classification/ID-resolution behavior, not as the architecture shape to copy.
3. Validate live/read-only Sirna fixtures for `0003`, warehouse IDs, and stock
reagent rows.
4. Refactor the shared Bioyond synchronizer to expose classification,
resolution, and non-slot row hooks with default behavior matching existing
stations.
5. Make Sirna install hooks before the first external sync and first deck
publication through the shared lifecycle.
6. Route startup, manual resync, reset resync, and report-triggered resync
through the installed synchronizer.
7. Converge stock sync and create-order registration on one
resolver/classifier/apply implementation.
8. Add stale-state diffing only after the basic ID-first path is stable.
9. Harden graphio fallback as a shared follow-up.
This gives Sirna the necessary behavior without locking in a second Bioyond sync
system that future projects will have to debug around.

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.11.1 version: 0.11.3
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
target_directory: src target_directory: src

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: "0.11.1" version: "0.11.3"
source: source:
path: ../.. path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup( setup(
name=package_name, name=package_name,
version='0.11.1', version='0.11.3',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=['setuptools'], install_requires=['setuptools'],

View File

@@ -1 +1 @@
__version__ = "0.11.1" __version__ = "0.11.3"

View File

@@ -59,6 +59,7 @@ class JobAddReq(BaseModel):
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="") task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="") job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
node_id: str = Field(examples=["node_id"], description="node uuid", default="") node_id: str = Field(examples=["node_id"], description="node uuid", default="")
notebook_id: str = Field(examples=["notebook_id"], description="notebook uuid", default="")
server_info: dict = Field( server_info: dict = Field(
examples=[{"send_timestamp": 1717000000.0}], examples=[{"send_timestamp": 1717000000.0}],
description="server info (auto-generated if empty)", description="server info (auto-generated if empty)",

View File

@@ -10,29 +10,170 @@ import shutil
import sys import sys
_PATCH_MARKER = "# UniLabOS DLL Patch"
_PATCH_END_MARKER = "# End UniLabOS DLL Patch"
# 75 = EX_TEMPFAIL: 临时失败、重试即可,避免与业务退出码冲突
_RESTART_EXIT_CODE = 75
def _build_dll_patch(lib_bin: str, preload_pyd: str = "") -> str:
"""生成一段加在目标文件顶部的 DLL 加载补丁源码。
- 始终把 ``lib_bin`` 加入 DLL 搜索路径,并把 handle 挂在模块属性上,
防止 GC 清掉搜索路径(``os.add_dll_directory`` 的句柄被回收时
目录会被移除)。
- 可选地用 ``ctypes.CDLL`` 预加载一个 .pyd把它的依赖 DLL 提前装入
进程内存,作为 ``rclpy._rclpy_pybind11`` 这类首次加载点的兜底。
"""
# 用 repr() 序列化路径Python 解析 repr 的结果会还原成原始字符串,
# 不需要也不能再叠加 raw-string 前缀(叠了反而会让 \\ 变成两个反斜杠)。
lines = [
_PATCH_MARKER,
"import os as _ulab_os",
f"_ulab_p = {lib_bin!r}",
'if hasattr(_ulab_os, "add_dll_directory") and _ulab_os.path.isdir(_ulab_p):',
" try: _UNILAB_DLL_HANDLE = _ulab_os.add_dll_directory(_ulab_p)",
" except Exception: _UNILAB_DLL_HANDLE = None",
]
if preload_pyd:
lines.extend(
[
"import ctypes as _ulab_ctypes",
f"try: _ulab_ctypes.CDLL({preload_pyd!r})",
"except Exception: pass",
]
)
lines.append(_PATCH_END_MARKER)
return "\n".join(lines) + "\n"
def _apply_dll_patch(file_path: str, lib_bin: str, preload_pyd: str = "") -> bool:
"""把 DLL 补丁前置到 ``file_path``。文件不存在或已打过补丁则返回 False。"""
if not os.path.isfile(file_path):
return False
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
if _PATCH_MARKER in content:
return False
shutil.copy2(file_path, file_path + ".bak")
with open(file_path, "w", encoding="utf-8") as f:
f.write(_build_dll_patch(lib_bin, preload_pyd) + content)
return True
def _print_restart_banner(patched_files):
"""打印重启提示并以 EX_TEMPFAIL 退出。
- 不使用 ANSI 颜色码Windows 旧版 cmd / PowerShell 5 默认不开 VT 处理,
会把 ``\\033[1;33m`` 当做字面字符显示,反而让用户看不到正文。
- 同时写入 stderr 与 stdout某些上层 launcher / supervisor 只重定向
其中一路,写两遍能保证用户至少看到一份。
- 写入前防御性把流切到 UTF-8 with replace``main.py`` 里已经做过一次,
但本模块也可能被绕过 ``main.py`` 的代码路径直接 importreconfigure
失败也只是退回 errors=replace不影响整体流程。
"""
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
bar = "#" * 78
files_lines = [f"[UniLabOS] - {p}" for p in patched_files]
body = "\n".join(
[
"",
bar,
bar,
"##",
"## [UniLabOS] Windows + conda 下检测到 DLL 加载失败,已自动打补丁。",
"## [UniLabOS] DLL load failure detected on Windows + conda;",
"## [UniLabOS] the following files have been auto-patched:",
"##",
*[f"## {line}" for line in files_lines],
"##",
"## [UniLabOS] 当前进程的 rclpy 状态已损坏,补丁需要在新进程才生效。",
"## [UniLabOS] The current process is unusable; the patch only takes",
"## [UniLabOS] effect on a fresh process.",
"##",
"## >>> 请重新运行刚才的命令 / Please re-run the same command. <<<",
"##",
bar,
bar,
"",
]
)
for stream in (sys.stderr, sys.stdout):
try:
stream.write(body)
stream.flush()
except Exception:
try:
print(body, file=stream)
except Exception:
pass
sys.exit(_RESTART_EXIT_CODE)
def patch_rclpy_dll_windows(): def patch_rclpy_dll_windows():
"""在 Windows + conda 环境下 rclpy 打 DLL 加载补丁""" """在 Windows + conda 环境下修复 rclpy / rosidl typesupport 的 DLL 加载。
背景conda 安装的 ros 系列包,其原生扩展依赖 ``$CONDA_PREFIX/Library/bin``
下的 DLL只有 conda 环境被正确激活、且 PATH 中含 ``Library/bin`` 时,
``os.add_dll_directory`` 才能找到它们。当从快捷方式 / IDE / 子进程 /
没激活的 shell 启动 ``unilab`` 时,会出现 ``DLL load failed``。
本函数会:
1) 修补 ``rclpy/impl/implementation_singleton.py`` —— rclpy 自身的 C 扩展入口;
2) 修补 ``rpyutils/add_dll_directories.py`` —— 所有 ``*_s__rosidl_typesupport_c.pyd``
``geometry_msgs`` / ``std_msgs`` / ``sensor_msgs`` 等)的统一加载入口。
打完补丁后**必须重启进程**才能生效(当前进程的 rclpy 已经发生过
``ImportError``,子模块仍处于损坏状态)。因此函数会主动退出,并在
stdout/stderr 同时打印明显的重启提示,避免用户被后续报错淹没。
"""
if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"): if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"):
return return
try: try:
import rclpy import rclpy # noqa: F401
return return
except ImportError as e: except ImportError as e:
if not str(e).startswith("DLL load failed"): if not str(e).startswith("DLL load failed"):
return return
cp = os.environ["CONDA_PREFIX"] cp = os.environ["CONDA_PREFIX"]
impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py") lib_bin = os.path.join(cp, "Library", "bin")
pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd")) site_packages = os.path.join(cp, "Lib", "site-packages")
if not os.path.exists(impl) or not pyd: if not os.path.isdir(lib_bin):
return return
with open(impl, "r", encoding="utf-8") as f:
content = f.read() patched = []
lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/")
patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n' # 1) rclpy 自身的入口
shutil.copy2(impl, impl + ".bak") rclpy_impl = os.path.join(site_packages, "rclpy", "impl", "implementation_singleton.py")
with open(impl, "w", encoding="utf-8") as f: rclpy_pyd_matches = glob.glob(os.path.join(site_packages, "rclpy", "_rclpy_pybind11*.pyd"))
f.write(patch + content) rclpy_pyd = rclpy_pyd_matches[0] if rclpy_pyd_matches else ""
if rclpy_pyd and _apply_dll_patch(rclpy_impl, lib_bin, preload_pyd=rclpy_pyd):
patched.append(rclpy_impl)
# 2) rpyutils —— 所有 rosidl typesupport pyd 的加载点;放在 rclpy 之后
# 例geometry_msgs/geometry_msgs_s__rosidl_typesupport_c.pyd
rpyutils_dll = os.path.join(site_packages, "rpyutils", "add_dll_directories.py")
if _apply_dll_patch(rpyutils_dll, lib_bin):
patched.append(rpyutils_dll)
if not patched:
# 已经打过补丁但 rclpy 仍然加载失败:原因不是缺 DLL 搜索路径,
# 不要再次打补丁污染文件,让上层看到真实的 ImportError。
return
_print_restart_banner(patched)
patch_rclpy_dll_windows() patch_rclpy_dll_windows()

View File

@@ -320,6 +320,7 @@ def job_add(req: JobAddReq) -> JobData:
action_name=action_name, action_name=action_name,
task_id=task_id, task_id=task_id,
job_id=job_id, job_id=job_id,
notebook_id=req.notebook_id,
device_action_key=device_action_key, device_action_key=device_action_key,
) )

View File

@@ -59,6 +59,7 @@ class QueueItem:
action_name: str action_name: str
task_id: str task_id: str
job_id: str job_id: str
notebook_id: str
device_action_key: str device_action_key: str
next_run_time: float = 0 # 下次执行时间戳 next_run_time: float = 0 # 下次执行时间戳
retry_count: int = 0 # 重试次数 retry_count: int = 0 # 重试次数
@@ -71,6 +72,7 @@ class JobInfo:
job_id: str job_id: str
task_id: str task_id: str
device_id: str device_id: str
notebook_id: str
action_name: str action_name: str
device_action_key: str device_action_key: str
status: JobStatus status: JobStatus
@@ -539,7 +541,10 @@ class MessageProcessor:
self.reconnect_count += 1 self.reconnect_count += 1
backoff = WSConfig.reconnect_interval backoff = WSConfig.reconnect_interval
logger.info( logger.info(
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})" "[MessageProcessor] 即将在 %s 秒后重连 (已尝试 %s/%s)",
backoff,
self.reconnect_count,
WSConfig.max_reconnect_attempts,
) )
await asyncio.sleep(backoff) await asyncio.sleep(backoff)
else: else:
@@ -703,6 +708,7 @@ class MessageProcessor:
action_name = data.get("action_name", "") action_name = data.get("action_name", "")
task_id = data.get("task_id", "") task_id = data.get("task_id", "")
job_id = data.get("job_id", "") job_id = data.get("job_id", "")
notebook_id = data.get("notebook_id", "")
if not all([device_id, action_name, task_id, job_id]): if not all([device_id, action_name, task_id, job_id]):
logger.error("[MessageProcessor] Missing required fields in query_action_state") logger.error("[MessageProcessor] Missing required fields in query_action_state")
@@ -718,6 +724,7 @@ class MessageProcessor:
job_id=job_id, job_id=job_id,
task_id=task_id, task_id=task_id,
device_id=device_id, device_id=device_id,
notebook_id=notebook_id,
action_name=action_name, action_name=action_name,
device_action_key=device_action_key, device_action_key=device_action_key,
status=JobStatus.QUEUE, status=JobStatus.QUEUE,
@@ -732,13 +739,27 @@ class MessageProcessor:
if can_start_immediately: if can_start_immediately:
# 可以立即开始 # 可以立即开始
await self._send_action_state_response( await self._send_action_state_response(
device_id, action_name, task_id, job_id, "query_action_status", True, 0 device_id,
action_name,
task_id,
job_id,
"query_action_status",
True,
0,
notebook_id=notebook_id,
) )
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately") logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
else: else:
# 需要排队 # 需要排队
await self._send_action_state_response( await self._send_action_state_response(
device_id, action_name, task_id, job_id, "query_action_status", False, 10 device_id,
action_name,
task_id,
job_id,
"query_action_status",
False,
10,
notebook_id=notebook_id,
) )
logger.trace(f"[MessageProcessor] Job {job_log} queued") logger.trace(f"[MessageProcessor] Job {job_log} queued")
@@ -768,6 +789,7 @@ class MessageProcessor:
job_id=req.job_id, job_id=req.job_id,
task_id=req.task_id, task_id=req.task_id,
device_id=req.device_id, device_id=req.device_id,
notebook_id=req.notebook_id,
action_name=action_name, action_name=action_name,
device_action_key=device_action_key, device_action_key=device_action_key,
status=JobStatus.QUEUE, status=JobStatus.QUEUE,
@@ -775,11 +797,16 @@ class MessageProcessor:
always_free=True, always_free=True,
) )
self.device_manager.add_queue_request(job_info) self.device_manager.add_queue_request(job_info)
existing_job = job_info
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
else: else:
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
return return
if existing_job and req.notebook_id and not existing_job.notebook_id:
existing_job.notebook_id = req.notebook_id
notebook_id = req.notebook_id or (existing_job.notebook_id if existing_job else "")
success = self.device_manager.start_job(req.job_id) success = self.device_manager.start_job(req.job_id)
if not success: if not success:
logger.error(f"[MessageProcessor] Failed to start job {job_log}") logger.error(f"[MessageProcessor] Failed to start job {job_log}")
@@ -795,6 +822,7 @@ class MessageProcessor:
action_name=req.action, action_name=req.action,
task_id=req.task_id, task_id=req.task_id,
job_id=req.job_id, job_id=req.job_id,
notebook_id=notebook_id,
device_action_key=device_action_key, device_action_key=device_action_key,
) )
@@ -834,6 +862,7 @@ class MessageProcessor:
"job_id": req.job_id, "job_id": req.job_id,
"task_id": req.task_id, "task_id": req.task_id,
"device_id": req.device_id, "device_id": req.device_id,
"notebook_id": queue_item.notebook_id,
"action_name": req.action, "action_name": req.action,
"status": "failed", "status": "failed",
"feedback_data": {}, "feedback_data": {},
@@ -855,6 +884,7 @@ class MessageProcessor:
"query_action_status", "query_action_status",
True, True,
0, 0,
notebook_id=next_job.notebook_id,
) )
next_job_log = format_job_log( next_job_log = format_job_log(
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
@@ -1004,11 +1034,16 @@ class MessageProcessor:
success = host_node.notify_resource_tree_update(dev_id, act, item_list) success = host_node.notify_resource_tree_update(dev_id, act, item_list)
if success: if success is True:
logger.info( logger.info(
f"[MessageProcessor] Resource tree {act} completed for device {dev_id}, " f"[MessageProcessor] Resource tree {act} completed for device {dev_id}, "
f"items: {len(item_list)}" f"items: {len(item_list)}"
) )
elif success is None:
logger.info(
f"[MessageProcessor] Resource tree {act} skipped for device {dev_id}: "
"在线增加设备暂不支持"
)
else: else:
logger.warning(f"[MessageProcessor] Resource tree {act} failed for device {dev_id}") logger.warning(f"[MessageProcessor] Resource tree {act} failed for device {dev_id}")
@@ -1032,6 +1067,11 @@ class MessageProcessor:
for item in device_list: for item in device_list:
target_node_id = item.get("target_node_id", "host_node") target_node_id = item.get("target_node_id", "host_node")
if action == "add":
logger.info(
f"[DeviceManage] 在线增加设备暂不支持,跳过 add_device: {item.get('id', '')}"
)
continue
def _notify(target_id: str, act: str, cfg: ResourceDictType): def _notify(target_id: str, act: str, cfg: ResourceDictType):
try: try:
@@ -1101,7 +1141,15 @@ class MessageProcessor:
logger.info(f"[MessageProcessor] Restart cleanup scheduled") logger.info(f"[MessageProcessor] Restart cleanup scheduled")
async def _send_action_state_response( async def _send_action_state_response(
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int self,
device_id: str,
action_name: str,
task_id: str,
job_id: str,
typ: str,
free: bool,
need_more: int,
notebook_id: str = "",
): ):
"""发送动作状态响应""" """发送动作状态响应"""
message = { message = {
@@ -1112,6 +1160,7 @@ class MessageProcessor:
"action_name": action_name, "action_name": action_name,
"task_id": task_id, "task_id": task_id,
"job_id": job_id, "job_id": job_id,
"notebook_id": notebook_id,
"free": free, "free": free,
"need_more": need_more + 1, "need_more": need_more + 1,
}, },
@@ -1194,6 +1243,7 @@ class QueueProcessor:
action_name=timeout_job.action_name, action_name=timeout_job.action_name,
task_id=timeout_job.task_id, task_id=timeout_job.task_id,
job_id=timeout_job.job_id, job_id=timeout_job.job_id,
notebook_id=timeout_job.notebook_id,
device_action_key=timeout_job.device_action_key, device_action_key=timeout_job.device_action_key,
) )
# 发布超时失败状态这会触发正常的job完成流程 # 发布超时失败状态这会触发正常的job完成流程
@@ -1252,6 +1302,7 @@ class QueueProcessor:
"action_name": job_info.action_name, "action_name": job_info.action_name,
"task_id": job_info.task_id, "task_id": job_info.task_id,
"job_id": job_info.job_id, "job_id": job_info.job_id,
"notebook_id": job_info.notebook_id,
"free": False, "free": False,
"need_more": 10 + 1, "need_more": 10 + 1,
}, },
@@ -1291,6 +1342,7 @@ class QueueProcessor:
"action_name": job_info.action_name, "action_name": job_info.action_name,
"task_id": job_info.task_id, "task_id": job_info.task_id,
"job_id": job_info.job_id, "job_id": job_info.job_id,
"notebook_id": job_info.notebook_id,
"free": False, "free": False,
"need_more": 10 + 1, "need_more": 10 + 1,
}, },
@@ -1336,12 +1388,15 @@ class QueueProcessor:
"action_name": next_job.action_name, "action_name": next_job.action_name,
"task_id": next_job.task_id, "task_id": next_job.task_id,
"job_id": next_job.job_id, "job_id": next_job.job_id,
"notebook_id": next_job.notebook_id,
"free": True, "free": True,
"need_more": 0, "need_more": 0,
}, },
} }
self.message_processor.send_message(message) self.message_processor.send_message(message)
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name) # next_job_log = format_job_log(
# next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
# )
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start") # logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
# 立即触发下一轮状态检查 # 立即触发下一轮状态检查
@@ -1510,6 +1565,7 @@ class WebSocketClient(BaseCommunicationClient):
"job_id": item.job_id, "job_id": item.job_id,
"task_id": item.task_id, "task_id": item.task_id,
"device_id": item.device_id, "device_id": item.device_id,
"notebook_id": item.notebook_id,
"action_name": item.action_name, "action_name": item.action_name,
"status": status, "status": status,
"feedback_data": feedback_data, "feedback_data": feedback_data,

View File

@@ -1,459 +0,0 @@
"""Per-action raw call/response log for Bioyond stations.
When a debug session is active, ``wrap_rpc_http`` replaces a ``BioyondV1RPC``
instance's ``post`` / ``get`` methods with closures that perform the HTTP
transport themselves, capture the request/response details, and append a record
to the active session before returning exactly what ``BaseRequest`` would have
returned. Outside of an active session the wrapped method delegates to the
original (unwrapped) implementation, leaving non-debug behavior intact.
The session writes a Markdown file under ``out_dir`` mirroring the format of
``temp_benyao/peptide/_logs/2026-04-30_160316_day3_samplefile_only_raw_calls.md``
minus the "Raw Payload Argument" section.
This module has no dependency on ``BioyondV1RPC`` itself; the only contract is
that the wrapped instance descends from ``BaseRequest`` (i.e. has a logger
returned by ``self.get_logger()``).
"""
from __future__ import annotations
import contextvars
import copy
import inspect
import json
import re
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, List, Optional
import requests
__all__ = [
"CallRecord",
"CallLogContext",
"session",
"wrap_rpc_http",
"active_session",
]
_DEFAULT_TIMEOUT_GET = 30
_DEFAULT_TIMEOUT_POST = 120
@dataclass
class CallRecord:
"""One captured HTTP call inside a debug session."""
index: int
method: str
url: str
path: str
source: str
transport: str
http_status: Optional[int]
request_body: Any
response_body: Any
error: Optional[str] = None
@dataclass
class CallLogContext:
"""State for a single ``session()`` block.
A session lazily creates its file on the first appended record. Actions
that abort before any RPC produce no file.
"""
action: str
out_dir: Path
started_at: datetime
calls: List[CallRecord] = field(default_factory=list)
file_path: Optional[Path] = None
def append(self, record: CallRecord) -> None:
record.index = len(self.calls) + 1
self.calls.append(record)
self._write_file()
# -- file I/O -------------------------------------------------------------
def _resolve_file_path(self) -> Path:
if self.file_path is not None:
return self.file_path
timestamp = self.started_at.strftime("%Y-%m-%d_%H%M%S")
slug = _slugify_action(self.action)
candidate = self.out_dir / f"{timestamp}_{slug}_raw_calls.md"
suffix = 2
while candidate.exists():
candidate = (
self.out_dir
/ f"{timestamp}_{slug}_raw_calls_{suffix:02d}.md"
)
suffix += 1
self.file_path = candidate
return self.file_path
def _write_file(self) -> None:
path = self._resolve_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_render_markdown(self), encoding="utf-8")
_active_session: contextvars.ContextVar[Optional[CallLogContext]] = (
contextvars.ContextVar("_active_session", default=None)
)
def active_session() -> Optional[CallLogContext]:
"""Return the currently active :class:`CallLogContext`, if any."""
return _active_session.get()
@contextmanager
def session(action: str, out_dir: Path) -> Iterator[CallLogContext]:
"""Open a per-action debug session.
On entry, sets the module-level ``_active_session`` ContextVar so any
``wrap_rpc_http``'d clients on the same thread/task record their calls.
On exit, the previous active session (if any) is restored.
"""
ctx = CallLogContext(
action=str(action),
out_dir=Path(out_dir),
started_at=datetime.now(),
)
token = _active_session.set(ctx)
try:
yield ctx
finally:
_active_session.reset(token)
def wrap_rpc_http(rpc: Any) -> None:
"""Idempotently wrap ``rpc.post`` / ``rpc.get``.
When a session is active (``_active_session.get() is not None``), the
wrapped methods perform the HTTP call themselves with ``requests`` and
record the call before returning the same value ``BaseRequest`` would have
returned. When no session is active, the wrapped methods delegate to the
original implementation, preserving stock ``BaseRequest`` behavior.
Calling this twice on the same instance is a no-op. The wrapper does not
alter ``rpc.form_post`` (no Sirna action calls it as of plan 3).
"""
if rpc is None:
return
if getattr(rpc, "_debug_call_log_wrapped", False):
return
rpc._orig_post = rpc.post
rpc._orig_get = rpc.get
def _wrapped_post(
url: str,
params: Any = None,
files: Any = None,
headers: Optional[dict] = None,
) -> Any:
ctx = _active_session.get()
if ctx is None:
kwargs = {}
if params is not None:
kwargs["params"] = params
if files is not None:
kwargs["files"] = files
if headers is not None:
kwargs["headers"] = headers
return rpc._orig_post(url, **kwargs)
effective_params = params if params is not None else {}
effective_headers = (
headers
if headers is not None
else {"Content-Type": "application/json"}
)
source = _detect_source(rpc)
request_body = _redact(effective_params)
record = CallRecord(
index=0,
method="POST",
url=str(url),
path=_url_path(url),
source=source,
transport=_pick_transport(effective_params),
http_status=None,
request_body=request_body,
response_body=None,
error=None,
)
return_value: Any = None
try:
response = requests.post(
url,
data=json.dumps(effective_params) if effective_params else None,
headers=effective_headers,
timeout=_DEFAULT_TIMEOUT_POST,
files=files,
)
except Exception as exc: # pragma: no cover - delegated to logger
record.error = f"transport error: {exc}"
try:
rpc.get_logger().error(f"Request ERROR: {exc}")
except Exception:
pass
ctx.append(record)
return None
record.http_status = response.status_code
record.response_body, parse_error = _decode_response_body(response)
try:
rpc.get_logger().debug(
f"Request >>> : {response.request.body} "
f"{response.status_code} {response.text}"
)
except Exception:
pass
if response.status_code == 200:
if parse_error is not None:
record.error = f"json parse error: {parse_error}"
return_value = None
else:
return_value = record.response_body
else:
record.error = f"HTTP {response.status_code}: {response.text}"
try:
rpc.get_logger().error(
f"Request ERROR: ('Request ERROR:', {response.text!r})"
)
except Exception:
pass
return_value = None
ctx.append(record)
return return_value
def _wrapped_get(
url: str,
params: Any = None,
headers: Optional[dict] = None,
) -> Any:
ctx = _active_session.get()
if ctx is None:
kwargs = {}
if params is not None:
kwargs["params"] = params
if headers is not None:
kwargs["headers"] = headers
return rpc._orig_get(url, **kwargs)
effective_params = params if params is not None else {}
effective_headers = (
headers
if headers is not None
else {"Content-Type": "application/json"}
)
source = _detect_source(rpc)
request_body = _redact(effective_params)
record = CallRecord(
index=0,
method="GET",
url=str(url),
path=_url_path(url),
source=source,
transport="params",
http_status=None,
request_body=request_body,
response_body=None,
error=None,
)
return_value: Any = None
try:
response = requests.get(
url,
params=effective_params,
headers=effective_headers,
timeout=_DEFAULT_TIMEOUT_GET,
)
except Exception as exc: # pragma: no cover - delegated to logger
record.error = f"transport error: {exc}"
try:
rpc.get_logger().error(f"Request ERROR: {exc}")
except Exception:
pass
ctx.append(record)
return None
record.http_status = response.status_code
record.response_body, parse_error = _decode_response_body(response)
try:
rpc.get_logger().debug(
f"Request >>> : {effective_params} "
f"{response.status_code} {response.text}"
)
except Exception:
pass
if response.status_code == 200:
if parse_error is not None:
record.error = f"json parse error: {parse_error}"
return_value = None
else:
return_value = record.response_body
ctx.append(record)
return return_value
rpc.post = _wrapped_post
rpc.get = _wrapped_get
rpc._debug_call_log_wrapped = True
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_URL_PATH_RE = re.compile(r"https?://[^/]+(/.*)?$")
_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+")
def _slugify_action(action: str) -> str:
slug = _SLUG_RE.sub("_", str(action)).strip("_")
return slug or "action"
def _url_path(url: Any) -> str:
text = str(url or "")
match = _URL_PATH_RE.match(text)
if match and match.group(1):
return match.group(1)
if text.startswith("/"):
return text
return text
def _pick_transport(params: Any) -> str:
if isinstance(params, dict) and "data" in params:
return "data"
return "params"
def _detect_source(rpc: Any) -> str:
"""Walk the call stack to find the outermost frame whose ``self`` is rpc."""
try:
stack = inspect.stack()
except Exception:
return ""
candidate = ""
try:
for frame_info in stack:
frame = frame_info.frame
if frame.f_locals.get("self", None) is rpc:
candidate = frame_info.function
return candidate
finally:
del stack
def _redact(params: Any) -> Any:
"""Return a copy of ``params`` with ``apiKey`` redacted."""
try:
cloned = copy.deepcopy(params)
except Exception:
return params
_redact_in_place(cloned)
return cloned
def _redact_in_place(value: Any) -> None:
if isinstance(value, dict):
for key in list(value.keys()):
if isinstance(key, str) and key.lower() == "apikey":
value[key] = "<redacted>"
else:
_redact_in_place(value[key])
elif isinstance(value, list):
for item in value:
_redact_in_place(item)
def _decode_response_body(response: Any) -> tuple[Any, Optional[str]]:
"""Best-effort response decoding used for both record + return value."""
text = getattr(response, "text", "")
try:
return response.json(), None
except Exception as exc:
if text:
return {"raw_text": text}, str(exc)
return None, str(exc)
# ---------------------------------------------------------------------------
# Markdown rendering
# ---------------------------------------------------------------------------
def _render_markdown(ctx: CallLogContext) -> str:
title = f"# {ctx.action} Raw Call/Response Log"
parts: List[str] = [title, ""]
parts.append("## LIMS Calls")
parts.append("")
parts.append("| # | Method | Path | Source | HTTP |")
parts.append("|---|---|---|---|---|")
for record in ctx.calls:
anchor = _row_anchor(record)
http = (
f"`{record.http_status}`"
if record.http_status is not None
else "`-`"
)
parts.append(
f"| [{record.index}](#{anchor}) | `{record.method}` | "
f"`{record.path}` | `{record.source}` | {http} |"
)
parts.append("")
for record in ctx.calls:
parts.append(f"## {record.index} {record.method} {record.path}")
parts.append("")
parts.append(f"- Source: `{record.source}`")
parts.append(f"- Transport: `{record.transport}`")
if record.http_status is not None:
parts.append(f"- HTTP status: `{record.http_status}`")
else:
parts.append("- HTTP status: `-`")
if record.error:
parts.append(f"- Error: {record.error}")
parts.append("")
parts.append("### Request Body")
parts.append("")
parts.append("```json")
parts.append(_to_json_block(record.request_body))
parts.append("```")
parts.append("")
parts.append("### Response Body")
parts.append("")
parts.append("```json")
parts.append(_to_json_block(record.response_body))
parts.append("```")
parts.append("")
return "\n".join(parts).rstrip() + "\n"
def _row_anchor(record: CallRecord) -> str:
"""Build a GitHub-style anchor matching ``## N METHOD /path``."""
raw = f"{record.index}-{record.method}-{record.path}"
raw = raw.lower()
raw = re.sub(r"[^a-z0-9]+", "-", raw)
return raw.strip("-")
def _to_json_block(value: Any) -> str:
try:
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
except TypeError:
return json.dumps(str(value), ensure_ascii=False, indent=2)

View File

@@ -1,3 +0,0 @@
from .sirna_station import BioyondSirnaStation, fetch_workflow_list, load_sirna_config
__all__ = ["BioyondSirnaStation", "fetch_workflow_list", "load_sirna_config"]

View File

@@ -7,7 +7,6 @@ Bioyond Workstation Implementation
import time import time
import traceback import traceback
import threading import threading
from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
import json import json
@@ -15,7 +14,6 @@ from pathlib import Path
from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
from unilabos.devices.workstation.bioyond_studio import debug_call_log
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.resources.warehouse import WareHouse from unilabos.resources.warehouse import WareHouse
from unilabos.utils.log import logger from unilabos.utils.log import logger
@@ -176,8 +174,6 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.warning("从Bioyond获取的物料数据为空") logger.warning("从Bioyond获取的物料数据为空")
return False return False
self._update_material_cache_from_stock(all_bioyond_data)
# 转换为UniLab格式 # 转换为UniLab格式
unilab_resources = resource_bioyond_to_plr( unilab_resources = resource_bioyond_to_plr(
all_bioyond_data, all_bioyond_data,
@@ -191,29 +187,6 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.error(f"从Bioyond同步物料数据失败: {e}") logger.error(f"从Bioyond同步物料数据失败: {e}")
return False return False
def _update_material_cache_from_stock(self, materials: List[Dict[str, Any]]) -> None:
"""用本次库存查询结果同步 RPC 的 name -> material id 缓存。"""
material_cache = getattr(self.bioyond_api_client, "material_cache", None)
if not isinstance(material_cache, dict):
return
before_count = len(material_cache)
for material in materials:
material_name = material.get("name")
material_id = material.get("id")
if material_name and material_id:
material_cache[material_name] = material_id
for detail_material in material.get("detail", []) or []:
detail_name = detail_material.get("name")
detail_id = detail_material.get("detailMaterialId") or detail_material.get("id")
if detail_name and detail_id:
material_cache[detail_name] = detail_id
logger.debug(
f"已用Bioyond库存同步物料缓存: {before_count} -> {len(material_cache)}"
)
def sync_to_external(self, resource: Any) -> bool: def sync_to_external(self, resource: Any) -> bool:
"""将本地物料数据变更同步到Bioyond系统""" """将本地物料数据变更同步到Bioyond系统"""
try: try:
@@ -705,70 +678,6 @@ class BioyondWorkstation(WorkstationBase):
集成Bioyond物料管理的工作站实现 集成Bioyond物料管理的工作站实现
""" """
# 子类(如 sirna / peptide覆写以指定默认 raw-call 日志目录。
# 路径相对仓库根;为 None 时若 debug_log=True 仍会写入临时位置。
_DEBUG_LOG_DEFAULT_DIR: Optional[str] = None
def _create_bioyond_rpc(self, config: Dict[str, Any]) -> BioyondV1RPC:
"""创建 Bioyond RPC 客户端并应用调试包装。
所有创建 ``BioyondV1RPC`` 的路径饿汉初始化、Sirna 延迟初始化、
以及未来的前端重新配置路径)都应通过该 helper
以确保 debug_log 包装与命名/日志策略保持一致。
"""
rpc = BioyondV1RPC(config)
debug_call_log.wrap_rpc_http(rpc)
return rpc
def _set_hardware_interface(self, rpc: BioyondV1RPC) -> BioyondV1RPC:
"""将已构造的 RPC 客户端设置到 ``self.hardware_interface``,并应用调试包装。"""
debug_call_log.wrap_rpc_http(rpc)
self.hardware_interface = rpc
return rpc
def _debug_log_resolved_dir(self) -> Path:
"""解析 ``debug_log_dir`` 为绝对路径。"""
configured = (getattr(self, "bioyond_config", {}) or {}).get("debug_log_dir")
default_dir = getattr(self, "_DEBUG_LOG_DEFAULT_DIR", None)
candidate = configured or default_dir or "temp_benyao/_logs/bioyond_debug"
path = Path(candidate)
if not path.is_absolute():
repo_root = Path(__file__).resolve().parents[4]
path = repo_root / path
return path
def _ensure_debug_log_state(self) -> None:
"""从 ``self.bioyond_config`` 派生 ``_debug_log_enabled`` / ``_debug_log_dir``。
每次进入 ``_debug_call_session`` 时都重新解析,以兼容前端在运行时
修改 ``bioyond_config['debug_log']`` 或目录的场景;同时也容忍
子类(如 Sirna 延迟初始化)在 ``__init__`` 早期未触发本方法。
"""
cfg = getattr(self, "bioyond_config", {}) or {}
self._debug_log_enabled = bool(cfg.get("debug_log"))
self._debug_log_dir = self._debug_log_resolved_dir()
@contextmanager
def _debug_call_session(self, action_name: str):
"""在 action 体外加一层 debug 会话上下文。
- ``debug_log`` 关闭时是空上下文,开销为 0。
- ``debug_log`` 开启时进入 :func:`debug_call_log.session`,所有
已被 ``wrap_rpc_http`` 包装过的 RPC 客户端都会捕获本次 action
产生的 HTTP 调用并写入 Markdown 文件。
子类(如 ``end_experiment``、``manual_unload`` 等)可以直接在
action 体里以 ``with self._debug_call_session("action_name"):`` 包裹。
"""
cfg = getattr(self, "bioyond_config", {}) or {}
enabled = bool(cfg.get("debug_log"))
if not enabled:
yield None
return
out_dir = BioyondWorkstation._debug_log_resolved_dir(self)
with debug_call_log.session(action_name, out_dir) as ctx:
yield ctx
def _publish_task_status( def _publish_task_status(
self, self,
task_id: str, task_id: str,
@@ -953,7 +862,7 @@ class BioyondWorkstation(WorkstationBase):
self.bioyond_config = {} self.bioyond_config = {}
print("警告: 未提供 bioyond_config请确保在 JSON 配置文件中提供完整配置") print("警告: 未提供 bioyond_config请确保在 JSON 配置文件中提供完整配置")
self.hardware_interface = self._create_bioyond_rpc(self.bioyond_config) self.hardware_interface = BioyondV1RPC(self.bioyond_config)
def resource_tree_add(self, resources: List[ResourcePLR]) -> None: def resource_tree_add(self, resources: List[ResourcePLR]) -> None:
"""添加资源到资源树并更新ROS节点 """添加资源到资源树并更新ROS节点
@@ -1429,7 +1338,11 @@ class BioyondWorkstation(WorkstationBase):
if self.hardware_interface: if self.hardware_interface:
self.hardware_interface.scheduler_reset() self.hardware_interface.scheduler_reset()
# 重新同步资源,并用同一次库存查询结果更新物料缓存 # 新物料缓存
if self.hardware_interface:
self.hardware_interface.refresh_material_cache()
# 重新同步资源
if self.resource_synchronizer: if self.resource_synchronizer:
self.resource_synchronizer.sync_from_external() self.resource_synchronizer.sync_from_external()

View File

@@ -1 +0,0 @@
from . import sirna_materials # noqa: F401 ensure @resource classes are importable for PLR deserialize

View File

@@ -1,8 +1,6 @@
from os import name from os import name
from pylabrobot.resources import Deck, Coordinate, Rotation from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.registry.decorators import resource
from unilabos.resources.bioyond.YB_warehouses import ( from unilabos.resources.bioyond.YB_warehouses import (
bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x4,
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05D08) bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05D08)
@@ -25,11 +23,6 @@ from unilabos.resources.bioyond.YB_warehouses import (
from unilabos.resources.bioyond.warehouses import ( from unilabos.resources.bioyond.warehouses import (
bioyond_warehouse_tipbox_storage_left, # 新增Tip盒堆栈(左) bioyond_warehouse_tipbox_storage_left, # 新增Tip盒堆栈(左)
bioyond_warehouse_tipbox_storage_right, # 新增Tip盒堆栈(右) bioyond_warehouse_tipbox_storage_right, # 新增Tip盒堆栈(右)
bioyond_warehouse_sirna_automation_stack,
bioyond_warehouse_sirna_centrifuge_balance_plate_stack,
bioyond_warehouse_sirna_g3_liquid_handler,
bioyond_warehouse_numeric_stack, # 新增:数字编码堆栈 (用于多肽站)
bioyond_warehouse_live_grid,
) )
@@ -108,83 +101,6 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items(): for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
@resource(
id="BIOYOND_SirnaStation_Deck",
category=["deck"],
description="BIOYOND 小核酸工作站 Deck",
icon="配液站.webp",
)
class BIOYOND_SirnaStation_Deck(Deck):
WAREHOUSE_BIOYOND_AXIS = {
"G3移液站": "xy_col_row",
"自动化堆栈": "xy_col_row",
"离心机配平板堆栈": "xy_col_row",
}
WAREHOUSE_BIOYOND_KEY_AXIS = {
"G3移液站": "col_row",
"自动化堆栈": "col_row",
"离心机配平板堆栈": "col_row",
}
# Bioyond warehouse UUID -> 本地仓库名称 映射。
# 留空时由配置station config 的 ``warehouse_bioyond_ids``)注入。
# graph 节点也可在 deck.config.warehouse_bioyond_ids 覆盖。
WAREHOUSE_BIOYOND_IDS: dict = {}
def __init__(
self,
name: str = "SirnaStation_Deck",
size_x: float = 2700.0,
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False,
warehouse_bioyond_ids: dict | None = None,
**kwargs,
) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
# 按需写入实例级覆盖;保留默认空 mapping避免改动模型常量。
self.warehouse_bioyond_ids: dict = dict(self.WAREHOUSE_BIOYOND_IDS)
if warehouse_bioyond_ids:
self.warehouse_bioyond_ids.update(warehouse_bioyond_ids)
if setup:
self.setup()
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
if data.get("children") and data.get("setup") is True:
data = data.copy()
data["setup"] = False
result = super().deserialize(data, allow_marshal=allow_marshal)
result._ensure_sirna_warehouse_metadata()
return result
def _ensure_sirna_warehouse_metadata(self) -> None:
for child in getattr(self, "children", []):
name = getattr(child, "name", "")
axis = self.WAREHOUSE_BIOYOND_AXIS.get(name)
if axis and not hasattr(child, "bioyond_axis"):
child.bioyond_axis = axis
key_axis = self.WAREHOUSE_BIOYOND_KEY_AXIS.get(name)
if key_axis and not hasattr(child, "bioyond_key_axis"):
child.bioyond_key_axis = key_axis
def setup(self) -> None:
# Sirna 读接口 /api/storage/location/locations-by-type 返回完整固定堆栈清单。
# LIMS 在库物料接口仍使用相同的 自动化堆栈 名称和数字库位编码。
self.warehouses = {
"G3移液站": bioyond_warehouse_sirna_g3_liquid_handler(),
"自动化堆栈": bioyond_warehouse_sirna_automation_stack(),
"离心机配平板堆栈": bioyond_warehouse_sirna_centrifuge_balance_plate_stack(),
}
self.warehouse_locations = {
"G3移液站": Coordinate(0.0, 0.0, 0.0),
"自动化堆栈": Coordinate(220.0, 0.0, 0.0),
"离心机配平板堆栈": Coordinate(1740.0, 0.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_YB_Deck(Deck): class BIOYOND_YB_Deck(Deck):
def __init__( def __init__(
self, self,
@@ -234,146 +150,12 @@ class BIOYOND_YB_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items(): for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
@resource(
id="BIOYOND_PeptideStation_Deck",
category=["deck"],
description="BIOYOND 多肽工作站 Deck",
icon="preparation_station.webp",
)
class BIOYOND_PeptideStation_Deck(Deck):
WAREHOUSE_BIOYOND_AXIS = dict.fromkeys(
[
"自动化堆栈",
"低温冰箱仓库",
"Tecan移液站库",
"G3移液站库",
"IDOT移液站库",
"G3缓冲库",
"盖板缓冲库",
"配平板缓冲库",
"IDOT缓冲库",
"固相合成板底座缓冲位",
"离心机库位",
"热封膜机位",
],
"xy_col_row",
)
WAREHOUSE_BIOYOND_KEY_AXIS = dict.fromkeys(WAREHOUSE_BIOYOND_AXIS, "row_col")
def __init__(
self,
name: str = "PeptideStation_Deck",
size_x: float = 3500.0,
size_y: float = 1800.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
if setup:
self.setup()
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
if data.get("children") and data.get("setup") is True:
# 已有序列化子资源,跳过 setup 避免重复创建
result = super(BIOYOND_PeptideStation_Deck, cls).deserialize(data, allow_marshal=allow_marshal)
else:
result = super(BIOYOND_PeptideStation_Deck, cls).deserialize(data, allow_marshal=allow_marshal)
result._ensure_peptide_warehouse_metadata()
return result
def _ensure_peptide_warehouse_metadata(self) -> None:
for child in getattr(self, "children", []):
name = getattr(child, "name", "")
axis = self.WAREHOUSE_BIOYOND_AXIS.get(name)
if axis and not hasattr(child, "bioyond_axis"):
child.bioyond_axis = axis
key_axis = self.WAREHOUSE_BIOYOND_KEY_AXIS.get(name)
if key_axis and not hasattr(child, "bioyond_key_axis"):
child.bioyond_key_axis = key_axis
def setup(self) -> None:
# 多肽工作站仓库配置
# 基于 2026-05-09 live API probe 发现的实际仓库拓扑 (12个仓库)
# 数据来源: temp_benyao/peptide/_logs/warehouse_discovery_raw_live_2026-05-09.json
self.warehouses = {
# 主自动化堆栈 - live API: code 10-17 -> x=17, y=10显示为 10 行×17 列
"自动化堆栈": bioyond_warehouse_numeric_stack(
"自动化堆栈", rows=10, columns=17, bioyond_axis="xy_col_row", bioyond_key_axis="row_col"
),
# 低温存储
"低温冰箱仓库": bioyond_warehouse_live_grid(
"低温冰箱仓库", rows=2, columns=3, slot_keys=["1", "2", "3", "4", "5", "6"]
),
# 移液站库位
"Tecan移液站库": bioyond_warehouse_live_grid(
"Tecan移液站库", rows=1, columns=18, slot_keys=[str(index) for index in range(1, 19)]
),
"G3移液站库": bioyond_warehouse_live_grid(
"G3移液站库",
rows=1,
columns=18,
slot_keys=["1", "2", "3", "4", "垃圾桶", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18"],
),
"IDOT移液站库": bioyond_warehouse_live_grid(
"IDOT移液站库",
rows=1,
columns=12,
slot_keys=[f"0009-{index:04d}" for index in range(1, 13)],
),
# 缓冲库位
"G3缓冲库": bioyond_warehouse_live_grid(
"G3缓冲库", rows=1, columns=5, slot_keys=[str(index) for index in range(1, 6)]
),
"盖板缓冲库": bioyond_warehouse_live_grid(
"盖板缓冲库", rows=1, columns=7, slot_keys=[str(index) for index in range(1, 8)]
),
"配平板缓冲库": bioyond_warehouse_live_grid(
"配平板缓冲库", rows=1, columns=3, slot_keys=[str(index) for index in range(1, 4)]
),
"IDOT缓冲库": bioyond_warehouse_live_grid(
"IDOT缓冲库", rows=1, columns=2, slot_keys=["1", "1"]
),
"固相合成板底座缓冲位": bioyond_warehouse_live_grid(
"固相合成板底座缓冲位",
rows=1,
columns=4,
slot_keys=[f"0015-{index:04d}" for index in range(1, 5)],
),
# 设备库位
"离心机库位": bioyond_warehouse_live_grid(
"离心机库位", rows=1, columns=4, slot_keys=[f"0017-{index:04d}" for index in range(1, 5)]
),
"热封膜机位": bioyond_warehouse_live_grid(
"热封膜机位", rows=1, columns=2, slot_keys=[f"0016-{index:04d}" for index in range(1, 3)]
),
}
# 仓库位置布局 (需根据实际硬件布局调整)
self.warehouse_locations = {
"自动化堆栈": Coordinate(0.0, 0.0, 0.0),
"Tecan移液站库": Coordinate(0.0, 1150.0, 0.0),
"G3移液站库": Coordinate(0.0, 1300.0, 0.0),
"IDOT移液站库": Coordinate(0.0, 1450.0, 0.0),
"G3缓冲库": Coordinate(0.0, 1600.0, 0.0),
"盖板缓冲库": Coordinate(850.0, 1600.0, 0.0),
"低温冰箱仓库": Coordinate(2700.0, 0.0, 0.0),
"配平板缓冲库": Coordinate(2700.0, 300.0, 0.0),
"IDOT缓冲库": Coordinate(2700.0, 450.0, 0.0),
"固相合成板底座缓冲位": Coordinate(2700.0, 600.0, 0.0),
"离心机库位": Coordinate(2700.0, 750.0, 0.0),
"热封膜机位": Coordinate(2700.0, 900.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
def YB_Deck(name: str) -> Deck: def YB_Deck(name: str) -> Deck:
by=BIOYOND_YB_Deck(name=name) by=BIOYOND_YB_Deck(name=name)
by.setup() by.setup()
return by return by

View File

@@ -1,126 +0,0 @@
"""Sirna Station Material Resource Definitions
Defines PyLabRobot resource classes for Bioyond Sirna station materials.
Each class is decorated with @resource for AST-based registry discovery.
"""
from collections import OrderedDict
from pylabrobot.resources import Plate, TipRack, Container
from unilabos.registry.decorators import resource
@resource(
id="bioyond_sirna_g3_200ul_tip_rack",
category=["labware", "tip_rack"],
description="G3-200ul枪头盒 for Sirna station",
)
class BioyondSirna_G3_200ul_TipRack(TipRack):
"""G3-200ul tip rack for Sirna liquid handling."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 64.0)
kwargs.setdefault("model", "bioyond_sirna_g3_200ul_tip_rack")
kwargs.setdefault("with_tips", True)
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
id="bioyond_sirna_g3_50ul_tip_rack",
category=["labware", "tip_rack"],
description="G3-50ul枪头盒 for Sirna station",
)
class BioyondSirna_G3_50ul_TipRack(TipRack):
"""G3-50ul tip rack for Sirna liquid handling."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 64.0)
kwargs.setdefault("model", "bioyond_sirna_g3_50ul_tip_rack")
kwargs.setdefault("with_tips", True)
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
id="bioyond_sirna_384_well_plate",
category=["labware", "plate"],
description="384孔板 for Sirna assays",
)
class BioyondSirna_384WellPlate(Plate):
"""384-well plate for Sirna reporter gene detection."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 14.35)
kwargs.setdefault("model", "bioyond_sirna_384_well_plate")
kwargs.setdefault("plate_type", "skirted")
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
id="bioyond_sirna_cell_culture_plate",
category=["labware", "plate"],
description="细胞培养板 for Sirna cell culture",
)
class BioyondSirna_CellCulturePlate(Plate):
"""Cell culture plate for Sirna experiments."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 14.35)
kwargs.setdefault("model", "bioyond_sirna_cell_culture_plate")
kwargs.setdefault("plate_type", "skirted")
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
id="bioyond_sirna_reagent_trough",
category=["labware", "trough"],
description="试剂槽 for Sirna reagents",
)
class BioyondSirna_ReagentTrough(Container):
"""Reagent trough for Sirna station reagents (RiboGreen, etc.)."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 44.0)
kwargs.setdefault("max_volume", 300000.0)
kwargs.setdefault("model", "bioyond_sirna_reagent_trough")
super().__init__(*args, **kwargs)
# Material type code mapping for dynamic instantiation
MATERIAL_TYPE_CODE_TO_CLASS = {
"0016": BioyondSirna_G3_200ul_TipRack,
"0017": BioyondSirna_G3_50ul_TipRack,
"0015": BioyondSirna_384WellPlate,
"0001": BioyondSirna_CellCulturePlate,
"0006": BioyondSirna_ReagentTrough,
}
def get_material_class_by_type_code(type_code: str):
"""Get resource class by Bioyond material type code.
Args:
type_code: Bioyond materialTypeCode (e.g., "0016", "0017")
Returns:
Resource class or None if not found
"""
return MATERIAL_TYPE_CODE_TO_CLASS.get(type_code)

View File

@@ -1,180 +1,5 @@
from pylabrobot.resources import Coordinate
from pylabrobot.resources.carrier import ResourceHolder, create_homogeneous_resources
from unilabos.resources.warehouse import WareHouse, warehouse_factory from unilabos.resources.warehouse import WareHouse, warehouse_factory
class BioyondWareHouse(WareHouse):
"""Bioyond 仓库,额外保存服务端 x/y 坐标和库位标签语义。"""
def __init__(self, *args, bioyond_axis: str = "xy_row_col", bioyond_key_axis: str = "row_col", **kwargs):
super().__init__(*args, **kwargs)
self.bioyond_axis = bioyond_axis
self.bioyond_key_axis = bioyond_key_axis
def serialize(self) -> dict:
data = super().serialize()
data["bioyond_axis"] = self.bioyond_axis
data["bioyond_key_axis"] = self.bioyond_key_axis
return data
def bioyond_warehouse_numeric_stack(
name: str,
rows: int = 10,
columns: int = 17,
bioyond_axis: str = "xy_row_col",
bioyond_key_axis: str = "row_col",
) -> WareHouse:
"""创建 Bioyond 数字库位堆栈,库位名使用服务端返回的 行-列 格式。
bioyond_axis: 仓库级别的 Bioyond 坐标轴约定,供 graphio 的坐标映射使用。
- "xy_row_col" (default): Bioyond x→row, y→col (reaction/peptide 历史约定).
- "xy_col_row": Bioyond x→col, y→row (Sirna live API 实测约定).
bioyond_key_axis: 库位标签生成约定。
- "row_col" (default): 视觉行列和标签行列一致,例如 10 行 x 17 列 → 1-1..10-17。
- "col_row": 视觉行列转置,但标签仍保持 Bioyond row-col例如
17 行 x 10 列 → 1-1..10-17。
未设置时 graphio 回退到默认 "xy_row_col",其他调用方保持原行为。
"""
num_items_x = columns
num_items_y = rows
num_items_z = 1
dx = 10.0
dy = 10.0
dz = 10.0
item_dx = 147.0
item_dy = 106.0
item_dz = 130.0
locations = [
Coordinate(dx + col * item_dx, dy + row * item_dy, dz)
for row in range(num_items_y)
for col in range(num_items_x)
]
holders = create_homogeneous_resources(
klass=ResourceHolder,
locations=locations,
resource_size_x=127.0,
resource_size_y=86.0,
resource_size_z=25.0,
name_prefix=name,
)
if bioyond_key_axis == "row_col":
keys = [
f"{row + 1}-{col + 1}"
for row in range(num_items_y)
for col in range(num_items_x)
]
elif bioyond_key_axis == "col_row":
keys = [
f"{col + 1}-{row + 1}"
for row in range(num_items_y)
for col in range(num_items_x)
]
else:
raise ValueError(f"未知 Bioyond 库位标签约定: {bioyond_key_axis!r}")
warehouse = BioyondWareHouse(
name=name,
size_x=dx + item_dx * num_items_x,
size_y=dy + item_dy * num_items_y,
size_z=dz + item_dz * num_items_z,
num_items_x=num_items_x,
num_items_y=num_items_y,
num_items_z=num_items_z,
ordering_layout="row-major",
sites={key: holder for key, holder in zip(keys, holders.values())},
category="warehouse",
bioyond_axis=bioyond_axis,
bioyond_key_axis=bioyond_key_axis,
)
return warehouse
def bioyond_warehouse_live_grid(
name: str,
rows: int,
columns: int,
slot_keys: list[str] | None = None,
bioyond_axis: str = "xy_col_row",
bioyond_key_axis: str = "row_col",
) -> WareHouse:
"""创建 Bioyond 实测库位网格,按服务端 code 保存位点标签。
默认用于 Peptide live API 返回的坐标x 是视觉列y 是视觉行。
当服务端 code 重复时,为保持 PLR ordering 唯一性,会给后续重复项追加 ``#N``。
"""
num_items_x = columns
num_items_y = rows
num_items_z = 1
dx = 10.0
dy = 10.0
dz = 10.0
item_dx = 147.0
item_dy = 106.0
item_dz = 130.0
locations = [
Coordinate(dx + col * item_dx, dy + row * item_dy, dz)
for row in range(num_items_y)
for col in range(num_items_x)
]
holders = create_homogeneous_resources(
klass=ResourceHolder,
locations=locations,
resource_size_x=127.0,
resource_size_y=86.0,
resource_size_z=25.0,
name_prefix=name,
)
keys = slot_keys or [str(index + 1) for index in range(num_items_x * num_items_y)]
if len(keys) != len(holders):
raise ValueError(f"{name} 库位数量不匹配: keys={len(keys)}, holders={len(holders)}")
seen: dict[str, int] = {}
unique_keys: list[str] = []
for key in keys:
count = seen.get(key, 0) + 1
seen[key] = count
unique_keys.append(key if count == 1 else f"{key}#{count}")
return BioyondWareHouse(
name=name,
size_x=dx + item_dx * num_items_x,
size_y=dy + item_dy * num_items_y,
size_z=dz + item_dz * num_items_z,
num_items_x=num_items_x,
num_items_y=num_items_y,
num_items_z=num_items_z,
ordering_layout="row-major",
sites={key: holder for key, holder in zip(unique_keys, holders.values())},
category="warehouse",
bioyond_axis=bioyond_axis,
bioyond_key_axis=bioyond_key_axis,
)
# ================ 小核酸工作站相关堆栈 ================
def bioyond_warehouse_sirna_g3_liquid_handler(name: str = "G3移液站") -> WareHouse:
"""创建小核酸 G3 移液站库位堆栈:显示为 14 行 x 1 列,标签保持 1-1..1-14。"""
return bioyond_warehouse_numeric_stack(
name, rows=14, columns=1, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
def bioyond_warehouse_sirna_automation_stack(name: str = "自动化堆栈") -> WareHouse:
"""创建小核酸自动化堆栈:显示为 17 行 x 10 列,标签保持 1-1..10-17。"""
return bioyond_warehouse_numeric_stack(
name, rows=17, columns=10, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
def bioyond_warehouse_sirna_centrifuge_balance_plate_stack(name: str = "离心机配平板堆栈") -> WareHouse:
"""创建小核酸离心机配平板堆栈:显示为 1 行 x 2 列,标签保持 1-1、2-1。"""
return bioyond_warehouse_numeric_stack(
name, rows=1, columns=2, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
# ================ 反应站相关堆栈 ================ # ================ 反应站相关堆栈 ================
def bioyond_warehouse_1x4x4(name: str) -> WareHouse: def bioyond_warehouse_1x4x4(name: str) -> WareHouse:

View File

@@ -736,7 +736,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}") logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}")
continue continue
plr_material.code = material.get("barCode") or material.get("code") or "" plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
plr_material.unilabos_uuid = str(uuid.uuid4()) plr_material.unilabos_uuid = str(uuid.uuid4())
# ⭐ 保存 Bioyond 原始信息到 unilabos_extra用于出库时查询 # ⭐ 保存 Bioyond 原始信息到 unilabos_extra用于出库时查询
@@ -864,22 +864,11 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
warehouse = deck.warehouses[wh_name] warehouse = deck.warehouses[wh_name]
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})") logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射: # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
# - 历史 row_col 仓库中 x/y 直接按行/列参与索引。 x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
# - Sirna 的库位标签为 col-rowstock-material 返回 x=标签第二段、y=标签第一段。 y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
# 因此 x=13,y=4 应落到 key=4-13而不是交换后落到 3-5。
x = loc.get("x", 1)
y = loc.get("y", 1)
z = loc.get("z", 1) # 层号 (1-based, 通常为1) z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 仓库级别的轴约定覆盖。
# 对旧的 row-col 视觉标签bioyond_axis="xy_col_row" 需要交换 x/y。
# 对 Sirna 的 col-row 库位标签,原始 x/y 已能直接索引到 code 对应位置,不再交换。
bioyond_axis = getattr(warehouse, "bioyond_axis", "xy_row_col")
bioyond_key_axis = getattr(warehouse, "bioyond_key_axis", "row_col")
if bioyond_axis == "xy_col_row" and bioyond_key_axis != "col_row":
x, y = y, x
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4) # 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
if wh_name == "堆栈1右": if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4 y = y - 4 # 将5-8映射到1-4
@@ -923,43 +912,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}")
if 0 <= idx < warehouse.capacity: if 0 <= idx < warehouse.capacity:
slot_key = None if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
ordering = getattr(warehouse, "_ordering", {})
sites = getattr(warehouse, "sites", [])
if isinstance(ordering, dict) and idx < len(sites):
site_at_idx = sites[idx]
slot_key = next(
(key for key, site in ordering.items() if site is site_at_idx),
None,
)
current_resource = warehouse[idx]
if current_resource is None or isinstance(current_resource, (ResourceHolder, str)):
if isinstance(current_resource, str):
logger.warning(
f"⚠️ 物料 {unique_name} 覆盖 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''} 的旧占位 occupied_by={current_resource!r}"
)
# 物料尺寸已在放入warehouse前根据需要进行了交换 # 物料尺寸已在放入warehouse前根据需要进行了交换
warehouse[idx] = plr_material warehouse[idx] = plr_material
logger.debug( logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''} "
f"(Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})"
)
else:
parent = getattr(current_resource, "parent", None)
current_repr = repr(current_resource)
current_len = len(current_resource) if isinstance(current_resource, str) else None
logger.warning(
f"⚠️ 物料 {unique_name} 跳过放置到 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''}:目标库位已有 "
f"{type(current_resource).__name__}"
f"(value={current_repr}, len={current_len})"
f"(name={getattr(current_resource, 'name', None)}, "
f"parent={getattr(parent, 'name', None)}, "
f"uuid={getattr(current_resource, 'unilabos_uuid', None)})"
)
else: else:
logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}") logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}")
else: else:

View File

@@ -45,6 +45,7 @@ from unilabos.resources.graphio import (
) )
from unilabos.resources.plr_additional_res_reg import register from unilabos.resources.plr_additional_res_reg import register
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
String,
convert_to_ros_msg, convert_to_ros_msg,
convert_from_ros_msg_with_mapping, convert_from_ros_msg_with_mapping,
convert_to_ros_msg_with_mapping, convert_to_ros_msg_with_mapping,
@@ -250,7 +251,8 @@ class PropertyPublisher:
): ):
self.node = node self.node = node
self.name = name self.name = name
self.msg_type = msg_type self.msg_type = self._normalize_msg_type(msg_type)
self.original_msg_type = msg_type
self.get_method = get_method self.get_method = get_method
self.timer_period = initial_period self.timer_period = initial_period
self.print_publish = print_publish self.print_publish = print_publish
@@ -258,16 +260,36 @@ class PropertyPublisher:
self._value = None self._value = None
try: try:
self.publisher_ = node.create_publisher(msg_type, f"{name}", qos) self.publisher_ = node.create_publisher(self.msg_type, f"{name}", qos)
except Exception as e: except Exception as e:
self.node.lab_logger().error( self.node.lab_logger().error(
f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {e}" f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败,"
f"可能由于注册表有误,类型: {msg_type},错误: {e}"
) )
self.msg_type = String
try:
self.publisher_ = node.create_publisher(self.msg_type, f"{name}", qos)
self.node.lab_logger().warning(
f"属性 {name} 的发布类型已降级为 String原始类型: {msg_type}"
)
except Exception:
self.publisher_ = None
self.timer = node.create_timer(self.timer_period, self.publish_property) self.timer = node.create_timer(self.timer_period, self.publish_property)
self.__loop = ROS2DeviceNode.get_asyncio_loop() self.__loop = ROS2DeviceNode.get_asyncio_loop()
str_msg_type = str(msg_type)[8:-2] str_msg_type = str(self.msg_type)[8:-2]
self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}") self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}")
@staticmethod
def _normalize_msg_type(msg_type):
if msg_type in (dict, list, tuple, set) or msg_type in ("dict", "list", "tuple", "set"):
return String
return msg_type
def _normalize_value(self, value):
if self.msg_type is String and isinstance(value, (dict, list, tuple, set)):
return json.dumps(value, ensure_ascii=False, cls=TypeEncoder)
return value
def get_property(self): def get_property(self):
if asyncio.iscoroutinefunction(self.get_method): if asyncio.iscoroutinefunction(self.get_method):
# 如果是异步函数,运行事件循环并等待结果 # 如果是异步函数,运行事件循环并等待结果
@@ -302,12 +324,16 @@ class PropertyPublisher:
pass pass
# self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}") # self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
if value is not None: if value is not None:
if self.publisher_ is None:
return
value = self._normalize_value(value)
msg = convert_to_ros_msg(self.msg_type, value) msg = convert_to_ros_msg(self.msg_type, value)
self.publisher_.publish(msg) self.publisher_.publish(msg)
# self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功") # self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
except Exception as e: except Exception as e:
topic = getattr(self.publisher_, "topic", self.name)
self.node.lab_logger().error( self.node.lab_logger().error(
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}" f"【.publish_property】发布属性 {topic} 出错: {str(e)}\n{traceback.format_exc()}"
) )
def change_frequency(self, period): def change_frequency(self, period):

View File

@@ -1691,7 +1691,9 @@ class HostNode(BaseROS2DeviceNode):
else: else:
self.lab_logger().warning("⚠️ 收到无效的Pong响应缺少ping_id") self.lab_logger().warning("⚠️ 收到无效的Pong响应缺少ping_id")
def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid_list: List[str]) -> bool: def notify_resource_tree_update(
self, device_id: str, action: str, resource_uuid_list: List[str]
) -> Optional[bool]:
""" """
通知设备节点更新资源树 通知设备节点更新资源树
@@ -1701,13 +1703,14 @@ class HostNode(BaseROS2DeviceNode):
resource_uuid_list: 资源UUIDs resource_uuid_list: 资源UUIDs
Returns: Returns:
bool: 操作是否成功 True if the update completed, False if it failed, None if it was intentionally skipped.
""" """
try: try:
# 检查设备是否存在
if device_id not in self.devices_names: if device_id not in self.devices_names:
self.lab_logger().error(f"[Host Node-Resource] Device {device_id} not found in devices_names") self.lab_logger().info(
return False f"[Host Node-Resource] 在线增加设备暂不支持,跳过设备 {device_id} 的资源树 {action} 更新"
)
return None
namespace = self.devices_names[device_id] namespace = self.devices_names[device_id]
device_key = f"{namespace}/{device_id}" device_key = f"{namespace}/{device_id}"

View File

@@ -47,7 +47,10 @@ def _has_uv() -> bool:
def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]:
if installer == "uv": if installer == "uv":
cmd = ["uv", "pip", "install"] # uv >= 0.5 默认要求虚拟环境,对 conda env 会报 "No virtual environment found"。
# 显式 --python sys.executable 让 uv 把当前解释器conda/venv/system 都行)
# 视为目标环境,绕开 venv 检测。
cmd = ["uv", "pip", "install", "--python", sys.executable]
if upgrade: if upgrade:
cmd.append("--upgrade") cmd.append("--upgrade")
cmd.append(package) cmd.append(package)
@@ -89,7 +92,11 @@ def _print_manual_git_install_hint(requirement: str) -> None:
return return
repo_dir = _repo_dir_name(git_url) repo_dir = _repo_dir_name(git_url)
install_cmd = "uv pip install -e ." if _has_uv() else f"{sys.executable} -m pip install -e ." install_cmd = (
f'uv pip install --python "{sys.executable}" -e .'
if _has_uv()
else f"{sys.executable} -m pip install -e ."
)
if _is_chinese_locale() and not _has_uv(): if _is_chinese_locale() and not _has_uv():
install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"

View File

@@ -2,7 +2,7 @@
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?> <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3"> <package format="3">
<name>unilabos_msgs</name> <name>unilabos_msgs</name>
<version>0.11.1</version> <version>0.11.3</version>
<description>ROS2 Messages package for unilabos devices</description> <description>ROS2 Messages package for unilabos devices</description>
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer> <maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer> <maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>